From e95c85183cb5322d490a7c3ac3bde7b760f97317 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Tue, 24 Mar 2026 01:58:08 +0700 Subject: [PATCH 01/30] config for my own --- .env | 15 +- ...ef8d_add_column_last_name_to_user_model.py | 39 + .../versions/312239f38f57_edit_user_model.py | 29 + backend/app/api/main.py | 5 +- backend/app/api/routes/files.py | 27 + backend/app/api/routes/image_parsers.py | 0 backend/app/api/routes/pdf_parsers.py | 0 backend/app/api/routes/storage.py | 38 + backend/app/api/routes/users.py | 2 +- backend/app/core/config.py | 14 +- backend/app/crud.py | 1 - backend/app/helpers/__init__.py | 0 backend/app/helpers/s3.py | 46 + backend/app/main.py | 20 +- backend/app/models.py | 32 + backend/pyproject.toml | 2 + frontend/src/routeTree.gen.ts | 6 +- package-lock.json | 9896 +++++++++++++++++ uv.lock | 58 +- 19 files changed, 10204 insertions(+), 26 deletions(-) create mode 100644 backend/app/alembic/versions/029973b1ef8d_add_column_last_name_to_user_model.py create mode 100644 backend/app/alembic/versions/312239f38f57_edit_user_model.py create mode 100644 backend/app/api/routes/files.py create mode 100644 backend/app/api/routes/image_parsers.py create mode 100644 backend/app/api/routes/pdf_parsers.py create mode 100644 backend/app/api/routes/storage.py create mode 100644 backend/app/helpers/__init__.py create mode 100644 backend/app/helpers/s3.py create mode 100644 package-lock.json diff --git a/.env b/.env index 1d44286e25..ed67871bf0 100644 --- a/.env +++ b/.env @@ -18,9 +18,9 @@ STACK_NAME=full-stack-fastapi-project # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis +SECRET_KEY=Ti100600@12131231 FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis +FIRST_SUPERUSER_PASSWORD=Ti100600@ # Emails SMTP_HOST= @@ -34,12 +34,19 @@ SMTP_PORT=587 # Postgres POSTGRES_SERVER=localhost POSTGRES_PORT=5432 -POSTGRES_DB=app +POSTGRES_DB=KeToanAuto POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_PASSWORD=Ti100600@ SENTRY_DSN= # Configure these with your own Docker registry images DOCKER_IMAGE_BACKEND=backend DOCKER_IMAGE_FRONTEND=frontend + + +# AWS S3 (Cloudflare R2) credentials +R2_ACCOUNT_ID="46252d78a71b1e948cca93580f21d6c8" +R2_ACCESS_KEY="fc94974446d24f787fc2c065211dd4b5" +R2_SECRET_KEY="7e2612409a61feb12a18d87cf960389e165b541680d660c86725de1e7cbbc753" +R2_BUCKET_NAME="ketoanauto" \ No newline at end of file diff --git a/backend/app/alembic/versions/029973b1ef8d_add_column_last_name_to_user_model.py b/backend/app/alembic/versions/029973b1ef8d_add_column_last_name_to_user_model.py new file mode 100644 index 0000000000..baa8c838b3 --- /dev/null +++ b/backend/app/alembic/versions/029973b1ef8d_add_column_last_name_to_user_model.py @@ -0,0 +1,39 @@ +"""Add column last_name to User model + +Revision ID: 029973b1ef8d +Revises: fe56fa70289e +Create Date: 2026-03-24 00:44:57.042280 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '029973b1ef8d' +down_revision = 'fe56fa70289e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('file', + sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('size', sa.Integer(), nullable=True), + sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('owner_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('file') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/312239f38f57_edit_user_model.py b/backend/app/alembic/versions/312239f38f57_edit_user_model.py new file mode 100644 index 0000000000..533fcef019 --- /dev/null +++ b/backend/app/alembic/versions/312239f38f57_edit_user_model.py @@ -0,0 +1,29 @@ +"""Edit User Model + +Revision ID: 312239f38f57 +Revises: 029973b1ef8d +Create Date: 2026-03-24 00:46:52.854728 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '312239f38f57' +down_revision = '029973b1ef8d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..ef8aca69ff 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, private, users, utils, storage, files from app.core.config import settings api_router = APIRouter() @@ -8,7 +8,8 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) - +api_router.include_router(storage.router) +api_router.include_router(files.router) if settings.ENVIRONMENT == "local": api_router.include_router(private.router) diff --git a/backend/app/api/routes/files.py b/backend/app/api/routes/files.py new file mode 100644 index 0000000000..f9d69b8679 --- /dev/null +++ b/backend/app/api/routes/files.py @@ -0,0 +1,27 @@ +import json +from sqlalchemy import update, null +from app.models import FileCreate, File +from app.api.deps import SessionDep, CurrentUser + +from fastapi import APIRouter, HTTPException, FastAPI, File, UploadFile +from app.helpers.s3 import upload_r2_file +router = APIRouter(prefix="/files", tags=["files"]) + +@router.post("/") +def upload_file( + *, session: SessionDep, file: UploadFile = File() +): + """ + Upload a file. + + This is a placeholder endpoint. In a real application, you would implement file upload logic here, + such as generating a presigned URL for S3/R2 or handling multipart form data. + """ + response = upload_r2_file('agr_sample1.png', file.file.read(), content_type=file.content_type) + return { + "filename": file.filename, + "content_type": file.content_type, + "size": len(file.file.read()), # Note: This reads the entire file into memory, which may not be ideal for large files. + "s3_response": response, + } + return None \ No newline at end of file diff --git a/backend/app/api/routes/image_parsers.py b/backend/app/api/routes/image_parsers.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/api/routes/pdf_parsers.py b/backend/app/api/routes/pdf_parsers.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/api/routes/storage.py b/backend/app/api/routes/storage.py new file mode 100644 index 0000000000..a97d668c05 --- /dev/null +++ b/backend/app/api/routes/storage.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.helpers import s3 +from app.core.config import settings + +router = APIRouter(prefix="/storage", tags=["storage"]) + + +class PresignRequest(BaseModel): + filename: str + content_type: str | None = None + # Optionally allow callers to choose a different bucket + bucket: str | None = None + + +class PresignResponse(BaseModel): + url: str + key: str + + +@router.post("/presign", response_model=PresignResponse) +def presign_upload(req: PresignRequest): + if not settings.R2_BUCKET_NAME and not req.bucket: + raise HTTPException(status_code=500, detail="S3 bucket not configured") + + # Create an object key. In a real app you may want to namespace by user/id, add + # random prefixes, validate filename, etc. Here we simply use the provided filename. + key = req.filename + + try: + url = s3.generate_presigned_put_url(key=key, bucket=req.bucket, expiration=3600) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + return PresignResponse(url=url, key=key) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 35f64b626e..2d89a57216 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -47,7 +47,7 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: ) users = session.exec(statement).all() - return UsersPublic(data=users, count=count) + return UsersPublic(data=list(users), count=count) @router.post( diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..5cdb475f11 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] - @computed_field # type: ignore[prop-decorator] + @computed_field @property def all_cors_origins(self) -> list[str]: return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ @@ -56,7 +56,7 @@ def all_cors_origins(self) -> list[str]: POSTGRES_PASSWORD: str = "" POSTGRES_DB: str = "" - @computed_field # type: ignore[prop-decorator] + @computed_field @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: return PostgresDsn.build( @@ -85,7 +85,7 @@ def _set_default_emails_from(self) -> Self: EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 - @computed_field # type: ignore[prop-decorator] + @computed_field @property def emails_enabled(self) -> bool: return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) @@ -94,6 +94,14 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str + # S3 / R2 settings (optional). If using Cloudflare R2, set S3_ENDPOINT_URL to your + # R2 endpoint and provide access keys. These are intentionally optional so local + # dev continues to work without them. + R2_ACCESS_KEY: str | None = None + R2_SECRET_KEY: str | None = None + R2_BUCKET_NAME: str | None = None + R2_ACCOUNT_ID: str | None = None + def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6444..cc628f7800 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -6,7 +6,6 @@ from app.core.security import get_password_hash, verify_password from app.models import Item, ItemCreate, User, UserCreate, UserUpdate - def create_user(*, session: Session, user_create: UserCreate) -> User: db_obj = User.model_validate( user_create, update={"hashed_password": get_password_hash(user_create.password)} diff --git a/backend/app/helpers/__init__.py b/backend/app/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/helpers/s3.py b/backend/app/helpers/s3.py new file mode 100644 index 0000000000..0caa0b14cc --- /dev/null +++ b/backend/app/helpers/s3.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import boto3 +from botocore.client import Config + +from app.core.config import settings + +def _get_s3_client(): + """Return a boto3 S3 client configured for S3-compatible endpoints (e.g., Cloudflare R2).""" + kwargs: dict = {} + if settings.R2_ACCESS_KEY: + kwargs["aws_access_key_id"] = settings.R2_ACCESS_KEY + if settings.R2_SECRET_KEY: + kwargs["aws_secret_access_key"] = settings.R2_SECRET_KEY + + endpoint = f"https://{settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + client_config = Config(signature_version="s3v4") + + return boto3.client("s3", endpoint_url=endpoint, config=client_config, **kwargs) + + +def generate_presigned_put_url(key: str, bucket: str | None = None, expiration: int = 3600) -> str: + bucket = bucket or settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured (S3_BUCKET)") + + client = _get_s3_client() + params = {"Bucket": bucket, "Key": key} + url = client.generate_presigned_url( + ClientMethod="put_object", Params=params, ExpiresIn=expiration + ) + return url + + +def upload_r2_file(key: str, data: bytes, bucket: str | None = None, content_type: str | None = None) -> dict: + bucket = bucket or settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured (S3_BUCKET)") + + client = _get_s3_client() + extra_args: dict = {} + if content_type: + extra_args["ContentType"] = content_type + + resp = client.put_object(Bucket=bucket, Key=key, Body=data, **extra_args) + return resp diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..10adb52ec6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +from typing import Any, cast + import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute @@ -20,14 +22,14 @@ def custom_generate_unique_id(route: APIRoute) -> str: generate_unique_id_function=custom_generate_unique_id, ) -# Set all CORS enabled origins -if settings.all_cors_origins: - app.add_middleware( - CORSMiddleware, - allow_origins=settings.all_cors_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) +# Allow all CORS origins (useful for local development). If you want to restrict +# origins in production, change this to use `settings.all_cors_origins` instead. +app.add_middleware( + cast(Any, CORSMiddleware), + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..e02d357768 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -54,6 +54,7 @@ class User(UserBase, table=True): sa_type=DateTime(timezone=True), # type: ignore ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + files: list["File"] = Relationship(back_populates="owner", cascade_delete=True) # Properties to return via API, id is always required @@ -127,3 +128,34 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=128) + + +# Files +class FileBase(SQLModel): + filename: str = Field(min_length=1, max_length=255) + content_type: str = Field(min_length=1, max_length=255) + size: int | None = None + +class FileCreate(FileBase): + url: str | None = None + pass + +class FilePublic(FileBase): + id: uuid.UUID + created_at: datetime | None = None + owner_id: uuid.UUID + +class FilesPublic(SQLModel): + data: list[FilePublic] + count: int + +class File(FileBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + owner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + owner: User | None = Relationship(back_populates="files") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 66b4d66683..be62799eb2 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,8 @@ dependencies = [ "sentry-sdk[fastapi]>=2.0.0,<3.0.0", "pyjwt<3.0.0,>=2.8.0", "pwdlib[argon2,bcrypt]>=0.3.0", + "boto3>=1.26.0", + "python-multipart>=0.0.22", ] [dependency-groups] diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 8849130b4c..08d665fef8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -65,6 +65,7 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ } as any) export interface FileRoutesByFullPath { + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute @@ -72,7 +73,6 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -99,6 +99,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/' | '/login' | '/recover-password' | '/reset-password' @@ -106,7 +107,6 @@ export interface FileRouteTypes { | '/admin' | '/items' | '/settings' - | '/' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -171,7 +171,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..431a76f8c3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9896 @@ +{ + "name": "fastapi-full-stack-template", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "fastapi-full-stack-template", + "workspaces": [ + "frontend" + ] + }, + "frontend": { + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.1", + "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router-devtools": "^1.163.3", + "@tanstack/react-table": "^8.21.3", + "axios": "1.13.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "form-data": "4.0.5", + "lucide-react": "^0.563.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.2.3", + "react-error-boundary": "^6.0.0", + "react-hook-form": "^7.68.0", + "react-icons": "^5.5.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.2.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.14", + "@hey-api/openapi-ts": "0.73.0", + "@playwright/test": "1.58.2", + "@tanstack/router-devtools": "^1.166.7", + "@tanstack/router-plugin": "^1.140.0", + "@types/node": "^25.5.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.3", + "dotenv": "^17.3.1", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^7.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz", + "integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==", + "dev": true, + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.8", + "@biomejs/cli-darwin-x64": "2.4.8", + "@biomejs/cli-linux-arm64": "2.4.8", + "@biomejs/cli-linux-arm64-musl": "2.4.8", + "@biomejs/cli-linux-x64": "2.4.8", + "@biomejs/cli-linux-x64-musl": "2.4.8", + "@biomejs/cli-win32-arm64": "2.4.8", + "@biomejs/cli-win32-x64": "2.4.8" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz", + "integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz", + "integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz", + "integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz", + "integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz", + "integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz", + "integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz", + "integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz", + "integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", + "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", + "dev": true, + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.73.0.tgz", + "integrity": "sha512-sUscR3OIGW0k9U//28Cu6BTp3XaogWMDORj9H+5Du9E5AvTT7LZbCEDvkLhebFOPkp2cZAQfd66HiZsiwssBcQ==", + "dev": true, + "dependencies": { + "@hey-api/json-schema-ref-parser": "1.0.6", + "ansi-colors": "4.1.3", + "c12": "2.0.1", + "color-support": "1.1.3", + "commander": "13.0.0", + "handlebars": "4.7.8", + "open": "10.1.2" + }, + "bin": { + "openapi-ts": "bin/index.cjs" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=22.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": "^5.5.3" + } + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "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==", + "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==", + "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==", + "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==" + }, + "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==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "node_modules/@swc/core": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz", + "integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.21", + "@swc/core-darwin-x64": "1.15.21", + "@swc/core-linux-arm-gnueabihf": "1.15.21", + "@swc/core-linux-arm64-gnu": "1.15.21", + "@swc/core-linux-arm64-musl": "1.15.21", + "@swc/core-linux-ppc64-gnu": "1.15.21", + "@swc/core-linux-s390x-gnu": "1.15.21", + "@swc/core-linux-x64-gnu": "1.15.21", + "@swc/core-linux-x64-musl": "1.15.21", + "@swc/core-win32-arm64-msvc": "1.15.21", + "@swc/core-win32-ia32-msvc": "1.15.21", + "@swc/core-win32-x64-msvc": "1.15.21" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz", + "integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz", + "integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz", + "integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz", + "integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz", + "integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz", + "integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz", + "integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz", + "integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz", + "integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz", + "integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz", + "integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz", + "integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.1.tgz", + "integrity": "sha512-RTa+CECs9hNMDR2qH4x6bZEjXXDqnjolwecCgwk7ItW1K9WDjUrnyTrtGv4o6aZc1/6uUMkF6DWlYt78uJpgxw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.95.1.tgz", + "integrity": "sha512-YkKrHSp2qLw9wTRr2XufvkAZzernq9/ZlDU2C5euzWGydxd1w9PMvP4D8mIItmROYjhrAkj0VeLQIPkMj0LRxg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.1.tgz", + "integrity": "sha512-lqWkRuVtcz9p68/LxxKlWS+M4ACuizJUkVPZryKj0RKE8Se6TD8HU7FNy1c1Mv9C1CPBfzj/p/xiXWK9VCsKJQ==", + "peer": true, + "dependencies": { + "@tanstack/query-core": "5.95.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.95.1.tgz", + "integrity": "sha512-A6IDsRQdhEBdmSDNrCz0MqsgLmEiPqA/3KsJY3EAL2D1OEtsin+c8p89oRdcGL2wOPVKNz54qDOImm6pl1dD3Q==", + "dependencies": { + "@tanstack/query-devtools": "5.95.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.95.1", + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.168.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.2.tgz", + "integrity": "sha512-zUDRM01m81xDCeTLHuqsvKR9zpf+bdfEhyadcUNSbO1930lIeOKLmMscUUNHWhc7Gqpi/V8Xl85QcJFAIAGmvQ==", + "peer": true, + "dependencies": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.2", + "@tanstack/router-core": "1.168.2", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-router-devtools": { + "version": "1.166.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.166.11.tgz", + "integrity": "sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==", + "dependencies": { + "@tanstack/router-devtools-core": "1.167.1" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.2", + "@tanstack/router-core": "^1.168.2", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/router-core": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.2.tgz", + "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", + "dependencies": { + "@tanstack/store": "0.9.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.168.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.2.tgz", + "integrity": "sha512-9wHR7syfY7y/qrvTvv8bugh6mrKk58TuiSQp44nbGW0BpE2+IIta1DBeL5jHr9AD1a+c5fVKSu/JXsKeniUc9w==", + "peer": true, + "dependencies": { + "@tanstack/history": "1.161.6", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2" + }, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-devtools": { + "version": "1.166.11", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.166.11.tgz", + "integrity": "sha512-jvFKr1fQ5pWMOZTILhitJc1kJt1wj8qqtRClVJvyD1AjHc1XINihkqK+R6+FmC8F2m+XOhKME4CSnTtJ6Nf34w==", + "dev": true, + "dependencies": { + "@tanstack/react-router-devtools": "1.166.11", + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.168.2", + "csstype": "^3.0.10", + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-devtools-core": { + "version": "1.167.1", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.167.1.tgz", + "integrity": "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==", + "dependencies": { + "clsx": "^2.1.1", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/router-core": "^1.168.2", + "csstype": "^3.0.10" + }, + "peerDependenciesMeta": { + "csstype": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.166.16", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.166.16.tgz", + "integrity": "sha512-5C9PUY8tGfx+J528SYt3MrvlbNy4pSfiiWpfAJ4dYPGkvMqc/NHbpt/cm7MaKKB1iVI/r0ZvbZGjYM1RKQGLtw==", + "dev": true, + "dependencies": { + "@tanstack/router-core": "1.168.2", + "@tanstack/router-utils": "1.161.6", + "@tanstack/virtual-file-routes": "1.161.7", + "prettier": "^3.5.0", + "recast": "^0.23.11", + "source-map": "^0.7.4", + "tsx": "^4.19.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.167.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.167.3.tgz", + "integrity": "sha512-mnaT0T3BtTvn5b7A31wchsh9cEeRjwhsvMtkVqtOmNKwviL6M9QdmwnfwqUK4YQslmaVSe6qoDsAN3gCF4tJDw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.168.2", + "@tanstack/router-generator": "1.166.16", + "@tanstack/router-utils": "1.161.6", + "@tanstack/virtual-file-routes": "1.161.7", + "chokidar": "^3.6.0", + "unplugin": "^2.1.2", + "zod": "^3.24.2" + }, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2", + "@tanstack/react-router": "^1.168.2", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", + "vite-plugin-solid": "^2.11.10", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-plugin/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.6.tgz", + "integrity": "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.161.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.161.7.tgz", + "integrity": "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==", + "dev": true, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "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 + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "devOptional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "peer": true, + "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==", + "devOptional": true, + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", + "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7", + "@swc/core": "^1.15.11" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "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, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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" + } + ], + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", + "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", + "dev": true, + "dependencies": { + "chokidar": "^4.0.1", + "confbox": "^0.1.7", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.3", + "jiti": "^2.3.0", + "mlly": "^1.7.1", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/c12/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/c12/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "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" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.10.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 + }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==" + }, + "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==", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frontend": { + "resolved": "frontend", + "link": true + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "devOptional": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", + "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", + "dev": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.5.4", + "pathe": "^2.0.3", + "tar": "^6.2.1" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "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, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbot": { + "version": "5.1.36", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.36.tgz", + "integrity": "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "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 + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "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" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true + }, + "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, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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 + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nypm": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", + "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", + "dev": true, + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "tinyexec": "^0.3.2", + "ufo": "^1.5.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "dev": true + }, + "node_modules/open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-error-boundary": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", + "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.72.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", + "peer": true, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==" + }, + "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, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", + "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.1.tgz", + "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==" + }, + "node_modules/tapable": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz", + "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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==" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.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==", + "devOptional": true + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + }, + "dependencies": { + "@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true + }, + "@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "peer": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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" + } + }, + "@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "requires": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true + }, + "@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "requires": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true + }, + "@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "requires": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + } + }, + "@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "requires": { + "@babel/types": "^7.29.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.28.6" + } + }, + "@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + } + }, + "@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@biomejs/biome": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz", + "integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==", + "dev": true, + "requires": { + "@biomejs/cli-darwin-arm64": "2.4.8", + "@biomejs/cli-darwin-x64": "2.4.8", + "@biomejs/cli-linux-arm64": "2.4.8", + "@biomejs/cli-linux-arm64-musl": "2.4.8", + "@biomejs/cli-linux-x64": "2.4.8", + "@biomejs/cli-linux-x64-musl": "2.4.8", + "@biomejs/cli-win32-arm64": "2.4.8", + "@biomejs/cli-win32-x64": "2.4.8" + } + }, + "@biomejs/cli-darwin-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz", + "integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==", + "dev": true, + "optional": true + }, + "@biomejs/cli-darwin-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz", + "integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz", + "integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-arm64-musl": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz", + "integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz", + "integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==", + "dev": true, + "optional": true + }, + "@biomejs/cli-linux-x64-musl": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz", + "integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==", + "dev": true, + "optional": true + }, + "@biomejs/cli-win32-arm64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz", + "integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==", + "dev": true, + "optional": true + }, + "@biomejs/cli-win32-x64": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz", + "integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==", + "dev": true, + "optional": true + }, + "@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "optional": true + }, + "@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "requires": { + "@floating-ui/utils": "^0.2.11" + } + }, + "@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "requires": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "requires": { + "@floating-ui/dom": "^1.7.6" + } + }, + "@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, + "@hey-api/json-schema-ref-parser": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", + "integrity": "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w==", + "dev": true, + "requires": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" + } + }, + "@hey-api/openapi-ts": { + "version": "0.73.0", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.73.0.tgz", + "integrity": "sha512-sUscR3OIGW0k9U//28Cu6BTp3XaogWMDORj9H+5Du9E5AvTT7LZbCEDvkLhebFOPkp2cZAQfd66HiZsiwssBcQ==", + "dev": true, + "requires": { + "@hey-api/json-schema-ref-parser": "1.0.6", + "ansi-colors": "4.1.3", + "c12": "2.0.1", + "color-support": "1.1.3", + "commander": "13.0.0", + "handlebars": "4.7.8", + "open": "10.1.2" + } + }, + "@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "requires": { + "@standard-schema/utils": "^0.3.0" + } + }, + "@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==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@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==" + }, + "@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==" + }, + "@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==", + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true + }, + "@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "requires": { + "playwright": "1.58.2" + } + }, + "@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "requires": { + "@radix-ui/react-primitive": "2.1.3" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-avatar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "requires": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "requires": {} + }, + "@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "requires": {} + }, + "@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "requires": {} + }, + "@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "requires": {} + }, + "@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "requires": { + "@radix-ui/react-primitive": "2.1.4" + } + }, + "@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "requires": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "requires": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "requires": { + "@radix-ui/react-slot": "1.2.4" + } + }, + "@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "requires": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "requires": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "requires": { + "@radix-ui/react-primitive": "2.1.4" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + }, + "@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "requires": {} + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "requires": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "requires": { + "@radix-ui/react-use-callback-ref": "1.1.1" + } + }, + "@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "requires": { + "use-sync-external-store": "^1.5.0" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "requires": {} + }, + "@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "requires": {} + }, + "@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "requires": { + "@radix-ui/rect": "1.1.1" + } + }, + "@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "requires": { + "@radix-ui/react-primitive": "2.1.3" + }, + "dependencies": { + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, + "@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, + "@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "optional": true + }, + "@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "optional": true + }, + "@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "optional": true + }, + "@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "optional": true + }, + "@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "@swc/core": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz", + "integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==", + "dev": true, + "requires": { + "@swc/core-darwin-arm64": "1.15.21", + "@swc/core-darwin-x64": "1.15.21", + "@swc/core-linux-arm-gnueabihf": "1.15.21", + "@swc/core-linux-arm64-gnu": "1.15.21", + "@swc/core-linux-arm64-musl": "1.15.21", + "@swc/core-linux-ppc64-gnu": "1.15.21", + "@swc/core-linux-s390x-gnu": "1.15.21", + "@swc/core-linux-x64-gnu": "1.15.21", + "@swc/core-linux-x64-musl": "1.15.21", + "@swc/core-win32-arm64-msvc": "1.15.21", + "@swc/core-win32-ia32-msvc": "1.15.21", + "@swc/core-win32-x64-msvc": "1.15.21", + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + } + }, + "@swc/core-darwin-arm64": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz", + "integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==", + "dev": true, + "optional": true + }, + "@swc/core-darwin-x64": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz", + "integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm-gnueabihf": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz", + "integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz", + "integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm64-musl": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz", + "integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==", + "dev": true, + "optional": true + }, + "@swc/core-linux-ppc64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz", + "integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==", + "dev": true, + "optional": true + }, + "@swc/core-linux-s390x-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz", + "integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==", + "dev": true, + "optional": true + }, + "@swc/core-linux-x64-gnu": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz", + "integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==", + "dev": true, + "optional": true + }, + "@swc/core-linux-x64-musl": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz", + "integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==", + "dev": true, + "optional": true + }, + "@swc/core-win32-arm64-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz", + "integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==", + "dev": true, + "optional": true + }, + "@swc/core-win32-ia32-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz", + "integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==", + "dev": true, + "optional": true + }, + "@swc/core-win32-x64-msvc": { + "version": "1.15.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz", + "integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==", + "dev": true, + "optional": true + }, + "@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true + }, + "@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "requires": { + "@swc/counter": "^0.1.3" + } + }, + "@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "requires": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "requires": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "optional": true + }, + "@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "optional": true + }, + "@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "optional": true + }, + "@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "optional": true + }, + "@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "optional": true + }, + "@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "optional": true + }, + "@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "optional": true + }, + "@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "optional": true + }, + "@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "optional": true + }, + "@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "optional": true, + "requires": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + } + }, + "@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "optional": true + }, + "@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "optional": true + }, + "@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "requires": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + } + }, + "@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==" + }, + "@tanstack/query-core": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.1.tgz", + "integrity": "sha512-RTa+CECs9hNMDR2qH4x6bZEjXXDqnjolwecCgwk7ItW1K9WDjUrnyTrtGv4o6aZc1/6uUMkF6DWlYt78uJpgxw==" + }, + "@tanstack/query-devtools": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.95.1.tgz", + "integrity": "sha512-YkKrHSp2qLw9wTRr2XufvkAZzernq9/ZlDU2C5euzWGydxd1w9PMvP4D8mIItmROYjhrAkj0VeLQIPkMj0LRxg==" + }, + "@tanstack/react-query": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.1.tgz", + "integrity": "sha512-lqWkRuVtcz9p68/LxxKlWS+M4ACuizJUkVPZryKj0RKE8Se6TD8HU7FNy1c1Mv9C1CPBfzj/p/xiXWK9VCsKJQ==", + "peer": true, + "requires": { + "@tanstack/query-core": "5.95.1" + } + }, + "@tanstack/react-query-devtools": { + "version": "5.95.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.95.1.tgz", + "integrity": "sha512-A6IDsRQdhEBdmSDNrCz0MqsgLmEiPqA/3KsJY3EAL2D1OEtsin+c8p89oRdcGL2wOPVKNz54qDOImm6pl1dD3Q==", + "requires": { + "@tanstack/query-devtools": "5.95.1" + } + }, + "@tanstack/react-router": { + "version": "1.168.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.2.tgz", + "integrity": "sha512-zUDRM01m81xDCeTLHuqsvKR9zpf+bdfEhyadcUNSbO1930lIeOKLmMscUUNHWhc7Gqpi/V8Xl85QcJFAIAGmvQ==", + "peer": true, + "requires": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.2", + "@tanstack/router-core": "1.168.2", + "isbot": "^5.1.22" + } + }, + "@tanstack/react-router-devtools": { + "version": "1.166.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-router-devtools/-/react-router-devtools-1.166.11.tgz", + "integrity": "sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==", + "requires": { + "@tanstack/router-devtools-core": "1.167.1" + } + }, + "@tanstack/react-store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.2.tgz", + "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", + "requires": { + "@tanstack/store": "0.9.2", + "use-sync-external-store": "^1.6.0" + } + }, + "@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "requires": { + "@tanstack/table-core": "8.21.3" + } + }, + "@tanstack/router-core": { + "version": "1.168.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.2.tgz", + "integrity": "sha512-9wHR7syfY7y/qrvTvv8bugh6mrKk58TuiSQp44nbGW0BpE2+IIta1DBeL5jHr9AD1a+c5fVKSu/JXsKeniUc9w==", + "peer": true, + "requires": { + "@tanstack/history": "1.161.6", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2" + } + }, + "@tanstack/router-devtools": { + "version": "1.166.11", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools/-/router-devtools-1.166.11.tgz", + "integrity": "sha512-jvFKr1fQ5pWMOZTILhitJc1kJt1wj8qqtRClVJvyD1AjHc1XINihkqK+R6+FmC8F2m+XOhKME4CSnTtJ6Nf34w==", + "dev": true, + "requires": { + "@tanstack/react-router-devtools": "1.166.11", + "clsx": "^2.1.1", + "goober": "^2.1.16" + } + }, + "@tanstack/router-devtools-core": { + "version": "1.167.1", + "resolved": "https://registry.npmjs.org/@tanstack/router-devtools-core/-/router-devtools-core-1.167.1.tgz", + "integrity": "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==", + "requires": { + "clsx": "^2.1.1", + "goober": "^2.1.16" + } + }, + "@tanstack/router-generator": { + "version": "1.166.16", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.166.16.tgz", + "integrity": "sha512-5C9PUY8tGfx+J528SYt3MrvlbNy4pSfiiWpfAJ4dYPGkvMqc/NHbpt/cm7MaKKB1iVI/r0ZvbZGjYM1RKQGLtw==", + "dev": true, + "requires": { + "@tanstack/router-core": "1.168.2", + "@tanstack/router-utils": "1.161.6", + "@tanstack/virtual-file-routes": "1.161.7", + "prettier": "^3.5.0", + "recast": "^0.23.11", + "source-map": "^0.7.4", + "tsx": "^4.19.2", + "zod": "^3.24.2" + }, + "dependencies": { + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true + } + } + }, + "@tanstack/router-plugin": { + "version": "1.167.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.167.3.tgz", + "integrity": "sha512-mnaT0T3BtTvn5b7A31wchsh9cEeRjwhsvMtkVqtOmNKwviL6M9QdmwnfwqUK4YQslmaVSe6qoDsAN3gCF4tJDw==", + "dev": true, + "requires": { + "@babel/core": "^7.28.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.168.2", + "@tanstack/router-generator": "1.166.16", + "@tanstack/router-utils": "1.161.6", + "@tanstack/virtual-file-routes": "1.161.7", + "chokidar": "^3.6.0", + "unplugin": "^2.1.2", + "zod": "^3.24.2" + }, + "dependencies": { + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true + } + } + }, + "@tanstack/router-utils": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.6.tgz", + "integrity": "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==", + "dev": true, + "requires": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + } + }, + "@tanstack/store": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==" + }, + "@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==" + }, + "@tanstack/virtual-file-routes": { + "version": "1.161.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.161.7.tgz", + "integrity": "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==", + "dev": true + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "@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 + }, + "@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "devOptional": true, + "peer": true, + "requires": { + "undici-types": "~7.18.0" + } + }, + "@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "peer": true, + "requires": { + "csstype": "^3.2.2" + } + }, + "@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==", + "devOptional": true, + "peer": true, + "requires": {} + }, + "@vitejs/plugin-react-swc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.3.0.tgz", + "integrity": "sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==", + "dev": true, + "requires": { + "@rolldown/pluginutils": "1.0.0-rc.7", + "@swc/core": "^1.15.11" + } + }, + "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 + }, + "ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true + }, + "ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "requires": { + "tslib": "^2.0.0" + } + }, + "ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "requires": { + "tslib": "^2.0.1" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "requires": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "requires": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "peer": true, + "requires": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + } + }, + "bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "requires": { + "run-applescript": "^7.0.0" + } + }, + "c12": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz", + "integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==", + "dev": true, + "requires": { + "chokidar": "^4.0.1", + "confbox": "^0.1.7", + "defu": "^6.1.4", + "dotenv": "^16.4.5", + "giget": "^1.2.3", + "jiti": "^2.3.0", + "mlly": "^1.7.1", + "ohash": "^1.1.4", + "pathe": "^1.1.2", + "perfect-debounce": "^1.0.0", + "pkg-types": "^1.2.0", + "rc9": "^2.1.2" + }, + "dependencies": { + "chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "requires": { + "readdirp": "^4.0.1" + } + }, + "dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true + }, + "pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true + } + } + }, + "call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + } + }, + "caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true + }, + "citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "requires": { + "consola": "^3.2.3" + } + }, + "class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "requires": { + "clsx": "^2.1.1" + } + }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", + "dev": true + }, + "confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true + }, + "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 + }, + "cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==" + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "peer": true + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "requires": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + } + }, + "default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true + }, + "define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true + }, + "defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true + }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + "detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true + }, + "dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true + }, + "dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "requires": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + } + }, + "electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + } + }, + "es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "requires": { + "es-errors": "^1.3.0" + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "requires": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + } + }, + "esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "requires": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, + "form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + } + }, + "frontend": { + "version": "file:frontend", + "requires": { + "@biomejs/biome": "^2.3.14", + "@hey-api/openapi-ts": "0.73.0", + "@hookform/resolvers": "^5.2.2", + "@playwright/test": "1.58.2", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.1", + "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router-devtools": "^1.163.3", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-devtools": "^1.166.7", + "@tanstack/router-plugin": "^1.140.0", + "@types/node": "^25.5.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.3", + "axios": "1.13.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dotenv": "^17.3.1", + "form-data": "4.0.5", + "lucide-react": "^0.563.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.2.3", + "react-error-boundary": "^6.0.0", + "react-hook-form": "^7.68.0", + "react-icons": "^5.5.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "zod": "^4.3.6" + } + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "requires": { + "minipass": "^3.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "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 + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "requires": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + } + }, + "get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" + }, + "get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "requires": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + } + }, + "get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "devOptional": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, + "giget": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz", + "integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==", + "dev": true, + "requires": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.5.4", + "pathe": "^2.0.3", + "tar": "^6.2.1" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "requires": {} + }, + "gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" + }, + "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==" + }, + "handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "requires": { + "has-symbols": "^1.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "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, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "requires": { + "is-docker": "^3.0.0" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "requires": { + "is-inside-container": "^1.0.0" + } + }, + "isbot": { + "version": "5.1.36", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.36.tgz", + "integrity": "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==" + }, + "jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==" + }, + "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 + }, + "js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "requires": { + "detect-libc": "^2.0.3", + "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" + } + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "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==", + "optional": true + }, + "lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true + }, + "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, + "requires": { + "yallist": "^3.0.2" + } + }, + "lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "requires": {} + }, + "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==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true + }, + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, + "mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "requires": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "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 + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "requires": {} + }, + "node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true + }, + "node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "nypm": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz", + "integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==", + "dev": true, + "requires": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "tinyexec": "^0.3.2", + "ufo": "^1.5.4" + } + }, + "ohash": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.6.tgz", + "integrity": "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==", + "dev": true + }, + "open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "requires": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + } + }, + "pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "requires": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.58.2" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true + }, + "postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "requires": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "peer": true + }, + "react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "peer": true, + "requires": { + "scheduler": "^0.27.0" + } + }, + "react-error-boundary": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", + "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", + "requires": {} + }, + "react-hook-form": { + "version": "7.72.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", + "peer": true, + "requires": {} + }, + "react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "requires": {} + }, + "react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "requires": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + } + }, + "react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "requires": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + } + }, + "react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "requires": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "requires": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true + }, + "rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "requires": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true + }, + "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==" + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "seroval": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", + "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", + "peer": true + }, + "seroval-plugins": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.1.tgz", + "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==", + "requires": {} + }, + "sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "requires": {} + }, + "source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true + }, + "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==" + }, + "tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==" + }, + "tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==" + }, + "tapable": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz", + "integrity": "sha512-b+u3CEM6FjDHru+nhUSjDofpWSBp2rINziJWgApm72wwGasQ/wKXftZe4tI2Y5HPv6OpzXSZHOFq87H4vfsgsw==" + }, + "tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true + }, + "tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "requires": {} + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true + } + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + } + }, + "tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true + }, + "typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "peer": true + }, + "ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true + }, + "uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "optional": true + }, + "undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true + }, + "unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "requires": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "dependencies": { + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + } + } + }, + "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, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "requires": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + } + }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "requires": {} + }, + "vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "peer": true, + "requires": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "fsevents": "~2.3.3", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "dependencies": { + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "requires": {} + }, + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "peer": true + } + } + }, + "webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==" + } + } +} diff --git a/uv.lock b/uv.lock index aef1e5bb8d..50c0d61c1c 100644 --- a/uv.lock +++ b/uv.lock @@ -64,6 +64,7 @@ version = "0.1.0" source = { editable = "backend" } dependencies = [ { name = "alembic" }, + { name = "boto3" }, { name = "email-validator" }, { name = "emails" }, { name = "fastapi", extra = ["standard"] }, @@ -92,6 +93,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.12.1,<2.0.0" }, + { name = "boto3", specifier = ">=1.26.0" }, { name = "email-validator", specifier = ">=2.1.0.post1,<3.0.0.0" }, { name = "emails", specifier = ">=0.6,<1.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, @@ -103,6 +105,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.2.1,<3.0.0" }, { name = "pyjwt", specifier = ">=2.8.0,<3.0.0" }, { name = "python-multipart", specifier = ">=0.0.7,<1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.22" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.0.0,<3.0.0" }, { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, { name = "tenacity", specifier = ">=8.2.3,<9.0.0" }, @@ -223,6 +226,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/13/47bba97924ebe86a62ef83dc75b7c8a881d53c535f83e2c54c4bd701e05c/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938", size = 280110, upload-time = "2025-02-28T01:24:05.896Z" }, ] +[[package]] +name = "boto3" +version = "1.42.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" }, +] + [[package]] name = "cachetools" version = "6.2.4" @@ -961,6 +992,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "librt" version = "0.7.8" @@ -1736,11 +1776,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.21" +version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] [[package]] @@ -1996,6 +2036,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "sentry-sdk" version = "2.52.0" From fe30d9ded67831ee25d68062bfeca5893e02bb71 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Wed, 25 Mar 2026 00:04:06 +0700 Subject: [PATCH 02/30] structure --- .../alembic/versions/c6971f55aa53_test1121.py | 29 +++ backend/app/api/deps.py | 67 +---- backend/app/api/main.py | 21 +- backend/app/api/routes/files.py | 29 +-- backend/app/api/routes/items.py | 114 +-------- backend/app/api/routes/login.py | 125 +--------- backend/app/api/routes/private.py | 11 +- backend/app/api/routes/storage.py | 40 +-- backend/app/api/routes/users.py | 233 +----------------- backend/app/api/routes/utils.py | 6 +- backend/app/auth/constants.py | 1 + backend/app/auth/dependencies.py | 64 +++++ backend/app/auth/exceptions.py | 16 ++ backend/app/auth/models.py | 1 + backend/app/auth/router.py | 118 +++++++++ backend/app/auth/schemas.py | 17 ++ backend/app/auth/service.py | 26 ++ backend/app/auth/utils.py | 30 +++ .../image_parsers.py => aws/__init__.py} | 0 backend/app/aws/client.py | 47 ++++ backend/app/aws/config.py | 17 ++ backend/app/aws/constants.py | 1 + backend/app/aws/exceptions.py | 6 + backend/app/aws/schemas.py | 12 + backend/app/aws/utils.py | 1 + backend/app/core/db.py | 23 +- backend/app/crud.py | 67 ----- .../pdf_parsers.py => files/__init__.py} | 0 backend/app/files/constants.py | 1 + backend/app/files/dependencies.py | 6 + backend/app/files/exceptions.py | 11 + backend/app/files/models.py | 31 +++ backend/app/files/router.py | 47 ++++ backend/app/files/schemas.py | 25 ++ backend/app/files/service.py | 14 ++ backend/app/files/utils.py | 1 + backend/app/helpers/s3.py | 48 +--- backend/app/items/__init__.py | 0 backend/app/items/constants.py | 1 + backend/app/items/dependencies.py | 6 + backend/app/items/exceptions.py | 11 + backend/app/items/models.py | 29 +++ backend/app/items/router.py | 108 ++++++++ backend/app/items/schemas.py | 32 +++ backend/app/items/service.py | 23 ++ backend/app/items/utils.py | 1 + backend/app/models.py | 198 +++------------ backend/app/users/__init__.py | 0 backend/app/users/constants.py | 1 + backend/app/users/dependencies.py | 8 + backend/app/users/exceptions.py | 16 ++ backend/app/users/models.py | 38 +++ backend/app/users/router.py | 225 +++++++++++++++++ backend/app/users/schemas.py | 59 +++++ backend/app/users/service.py | 37 +++ backend/app/users/utils.py | 96 ++++++++ backend/app/utils.py | 146 ++--------- 57 files changed, 1322 insertions(+), 1019 deletions(-) create mode 100644 backend/app/alembic/versions/c6971f55aa53_test1121.py create mode 100644 backend/app/auth/constants.py create mode 100644 backend/app/auth/dependencies.py create mode 100644 backend/app/auth/exceptions.py create mode 100644 backend/app/auth/models.py create mode 100644 backend/app/auth/router.py create mode 100644 backend/app/auth/schemas.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/auth/utils.py rename backend/app/{api/routes/image_parsers.py => aws/__init__.py} (100%) create mode 100644 backend/app/aws/client.py create mode 100644 backend/app/aws/config.py create mode 100644 backend/app/aws/constants.py create mode 100644 backend/app/aws/exceptions.py create mode 100644 backend/app/aws/schemas.py create mode 100644 backend/app/aws/utils.py delete mode 100644 backend/app/crud.py rename backend/app/{api/routes/pdf_parsers.py => files/__init__.py} (100%) create mode 100644 backend/app/files/constants.py create mode 100644 backend/app/files/dependencies.py create mode 100644 backend/app/files/exceptions.py create mode 100644 backend/app/files/models.py create mode 100644 backend/app/files/router.py create mode 100644 backend/app/files/schemas.py create mode 100644 backend/app/files/service.py create mode 100644 backend/app/files/utils.py create mode 100644 backend/app/items/__init__.py create mode 100644 backend/app/items/constants.py create mode 100644 backend/app/items/dependencies.py create mode 100644 backend/app/items/exceptions.py create mode 100644 backend/app/items/models.py create mode 100644 backend/app/items/router.py create mode 100644 backend/app/items/schemas.py create mode 100644 backend/app/items/service.py create mode 100644 backend/app/items/utils.py create mode 100644 backend/app/users/__init__.py create mode 100644 backend/app/users/constants.py create mode 100644 backend/app/users/dependencies.py create mode 100644 backend/app/users/exceptions.py create mode 100644 backend/app/users/models.py create mode 100644 backend/app/users/router.py create mode 100644 backend/app/users/schemas.py create mode 100644 backend/app/users/service.py create mode 100644 backend/app/users/utils.py diff --git a/backend/app/alembic/versions/c6971f55aa53_test1121.py b/backend/app/alembic/versions/c6971f55aa53_test1121.py new file mode 100644 index 0000000000..6ab365e6d3 --- /dev/null +++ b/backend/app/alembic/versions/c6971f55aa53_test1121.py @@ -0,0 +1,29 @@ +"""test1121 + +Revision ID: c6971f55aa53 +Revises: 312239f38f57 +Create Date: 2026-03-25 00:01:27.058092 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'c6971f55aa53' +down_revision = '312239f38f57' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c841d..485185d703 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,57 +1,10 @@ -from collections.abc import Generator -from typing import Annotated - -import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer -from jwt.exceptions import InvalidTokenError -from pydantic import ValidationError -from sqlmodel import Session - -from app.core import security -from app.core.config import settings -from app.core.db import engine -from app.models import TokenPayload, User - -reusable_oauth2 = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/login/access-token" -) - - -def get_db() -> Generator[Session, None, None]: - with Session(engine) as session: - yield session - - -SessionDep = Annotated[Session, Depends(get_db)] -TokenDep = Annotated[str, Depends(reusable_oauth2)] - - -def get_current_user(session: SessionDep, token: TokenDep) -> User: - try: - payload = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - token_data = TokenPayload(**payload) - except (InvalidTokenError, ValidationError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Could not validate credentials", - ) - user = session.get(User, token_data.sub) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - return user - - -CurrentUser = Annotated[User, Depends(get_current_user)] - - -def get_current_active_superuser(current_user: CurrentUser) -> User: - if not current_user.is_superuser: - raise HTTPException( - status_code=403, detail="The user doesn't have enough privileges" - ) - return current_user +# # Backwards-compatibility shim – imports are now in app.auth.dependencies +# from app.auth.dependencies import ( # noqa: F401 +# CurrentUser, +# SessionDep, +# TokenDep, +# get_current_active_superuser, +# get_current_user, +# get_db, +# reusable_oauth2, +# ) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index ef8aca69ff..13230d1625 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,15 +1,20 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils, storage, files +from app.api.routes.utils import router as utils_router +from app.auth.router import router as login_router from app.core.config import settings +from app.files.router import router as files_router +from app.items.router import router as items_router +from app.users.router import router as users_router api_router = APIRouter() -api_router.include_router(login.router) -api_router.include_router(users.router) -api_router.include_router(utils.router) -api_router.include_router(items.router) -api_router.include_router(storage.router) -api_router.include_router(files.router) +api_router.include_router(login_router) +api_router.include_router(users_router) +api_router.include_router(utils_router) +api_router.include_router(items_router) +api_router.include_router(files_router) if settings.ENVIRONMENT == "local": - api_router.include_router(private.router) + from app.api.routes.private import router as private_router + + api_router.include_router(private_router) diff --git a/backend/app/api/routes/files.py b/backend/app/api/routes/files.py index f9d69b8679..7b84ef6d2b 100644 --- a/backend/app/api/routes/files.py +++ b/backend/app/api/routes/files.py @@ -1,27 +1,2 @@ -import json -from sqlalchemy import update, null -from app.models import FileCreate, File -from app.api.deps import SessionDep, CurrentUser - -from fastapi import APIRouter, HTTPException, FastAPI, File, UploadFile -from app.helpers.s3 import upload_r2_file -router = APIRouter(prefix="/files", tags=["files"]) - -@router.post("/") -def upload_file( - *, session: SessionDep, file: UploadFile = File() -): - """ - Upload a file. - - This is a placeholder endpoint. In a real application, you would implement file upload logic here, - such as generating a presigned URL for S3/R2 or handling multipart form data. - """ - response = upload_r2_file('agr_sample1.png', file.file.read(), content_type=file.content_type) - return { - "filename": file.filename, - "content_type": file.content_type, - "size": len(file.file.read()), # Note: This reads the entire file into memory, which may not be ideal for large files. - "s3_response": response, - } - return None \ No newline at end of file +# Backwards-compatibility shim – router now lives in app.files.router +from app.files.router import router # noqa: F401 diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index f1929e5836..0a153b0e1e 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,112 +1,2 @@ -import uuid -from typing import Any - -from fastapi import APIRouter, HTTPException -from sqlmodel import col, func, select - -from app.api.deps import CurrentUser, SessionDep -from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message - -router = APIRouter(prefix="/items", tags=["items"]) - - -@router.get("/", response_model=ItemsPublic) -def read_items( - session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 -) -> Any: - """ - Retrieve items. - """ - - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() - statement = ( - select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit) - ) - items = session.exec(statement).all() - else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() - statement = ( - select(Item) - .where(Item.owner_id == current_user.id) - .order_by(col(Item.created_at).desc()) - .offset(skip) - .limit(limit) - ) - items = session.exec(statement).all() - - return ItemsPublic(data=items, count=count) - - -@router.get("/{id}", response_model=ItemPublic) -def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: - """ - Get item by ID. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=403, detail="Not enough permissions") - return item - - -@router.post("/", response_model=ItemPublic) -def create_item( - *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate -) -> Any: - """ - Create new item. - """ - item = Item.model_validate(item_in, update={"owner_id": current_user.id}) - session.add(item) - session.commit() - session.refresh(item) - return item - - -@router.put("/{id}", response_model=ItemPublic) -def update_item( - *, - session: SessionDep, - current_user: CurrentUser, - id: uuid.UUID, - item_in: ItemUpdate, -) -> Any: - """ - Update an item. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=403, detail="Not enough permissions") - update_dict = item_in.model_dump(exclude_unset=True) - item.sqlmodel_update(update_dict) - session.add(item) - session.commit() - session.refresh(item) - return item - - -@router.delete("/{id}") -def delete_item( - session: SessionDep, current_user: CurrentUser, id: uuid.UUID -) -> Message: - """ - Delete an item. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=403, detail="Not enough permissions") - session.delete(item) - session.commit() - return Message(message="Item deleted successfully") +# Backwards-compatibility shim – router now lives in app.items.router +from app.items.router import router # noqa: F401 diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 58441e37e9..2c5da51e57 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -1,123 +1,2 @@ -from datetime import timedelta -from typing import Annotated, Any - -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import HTMLResponse -from fastapi.security import OAuth2PasswordRequestForm - -from app import crud -from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser -from app.core import security -from app.core.config import settings -from app.models import Message, NewPassword, Token, UserPublic, UserUpdate -from app.utils import ( - generate_password_reset_token, - generate_reset_password_email, - send_email, - verify_password_reset_token, -) - -router = APIRouter(tags=["login"]) - - -@router.post("/login/access-token") -def login_access_token( - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] -) -> Token: - """ - OAuth2 compatible token login, get an access token for future requests - """ - user = crud.authenticate( - session=session, email=form_data.username, password=form_data.password - ) - if not user: - raise HTTPException(status_code=400, detail="Incorrect email or password") - elif not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return Token( - access_token=security.create_access_token( - user.id, expires_delta=access_token_expires - ) - ) - - -@router.post("/login/test-token", response_model=UserPublic) -def test_token(current_user: CurrentUser) -> Any: - """ - Test access token - """ - return current_user - - -@router.post("/password-recovery/{email}") -def recover_password(email: str, session: SessionDep) -> Message: - """ - Password Recovery - """ - user = crud.get_user_by_email(session=session, email=email) - - # Always return the same response to prevent email enumeration attacks - # Only send email if user actually exists - if user: - password_reset_token = generate_password_reset_token(email=email) - email_data = generate_reset_password_email( - email_to=user.email, email=email, token=password_reset_token - ) - send_email( - email_to=user.email, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return Message( - message="If that email is registered, we sent a password recovery link" - ) - - -@router.post("/reset-password/") -def reset_password(session: SessionDep, body: NewPassword) -> Message: - """ - Reset password - """ - email = verify_password_reset_token(token=body.token) - if not email: - raise HTTPException(status_code=400, detail="Invalid token") - user = crud.get_user_by_email(session=session, email=email) - if not user: - # Don't reveal that the user doesn't exist - use same error as invalid token - raise HTTPException(status_code=400, detail="Invalid token") - elif not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - user_in_update = UserUpdate(password=body.new_password) - crud.update_user( - session=session, - db_user=user, - user_in=user_in_update, - ) - return Message(message="Password updated successfully") - - -@router.post( - "/password-recovery-html-content/{email}", - dependencies=[Depends(get_current_active_superuser)], - response_class=HTMLResponse, -) -def recover_password_html_content(email: str, session: SessionDep) -> Any: - """ - HTML Content for Password Recovery - """ - user = crud.get_user_by_email(session=session, email=email) - - if not user: - raise HTTPException( - status_code=404, - detail="The user with this username does not exist in the system.", - ) - password_reset_token = generate_password_reset_token(email=email) - email_data = generate_reset_password_email( - email_to=user.email, email=email, token=password_reset_token - ) - - return HTMLResponse( - content=email_data.html_content, headers={"subject:": email_data.subject} - ) +# Backwards-compatibility shim – router now lives in app.auth.router +from app.auth.router import router # noqa: F401 diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py index 9f33ef1900..c36eb222b3 100644 --- a/backend/app/api/routes/private.py +++ b/backend/app/api/routes/private.py @@ -3,12 +3,10 @@ from fastapi import APIRouter from pydantic import BaseModel -from app.api.deps import SessionDep +from app.auth.dependencies import SessionDep from app.core.security import get_password_hash -from app.models import ( - User, - UserPublic, -) +from app.users.models import User +from app.users.schemas import UserPublic router = APIRouter(tags=["private"], prefix="/private") @@ -25,14 +23,11 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: """ Create a new user. """ - user = User( email=user_in.email, full_name=user_in.full_name, hashed_password=get_password_hash(user_in.password), ) - session.add(user) session.commit() - return user diff --git a/backend/app/api/routes/storage.py b/backend/app/api/routes/storage.py index a97d668c05..9146909c34 100644 --- a/backend/app/api/routes/storage.py +++ b/backend/app/api/routes/storage.py @@ -1,38 +1,2 @@ -from __future__ import annotations - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from app.helpers import s3 -from app.core.config import settings - -router = APIRouter(prefix="/storage", tags=["storage"]) - - -class PresignRequest(BaseModel): - filename: str - content_type: str | None = None - # Optionally allow callers to choose a different bucket - bucket: str | None = None - - -class PresignResponse(BaseModel): - url: str - key: str - - -@router.post("/presign", response_model=PresignResponse) -def presign_upload(req: PresignRequest): - if not settings.R2_BUCKET_NAME and not req.bucket: - raise HTTPException(status_code=500, detail="S3 bucket not configured") - - # Create an object key. In a real app you may want to namespace by user/id, add - # random prefixes, validate filename, etc. Here we simply use the provided filename. - key = req.filename - - try: - url = s3.generate_presigned_put_url(key=key, bucket=req.bucket, expiration=3600) - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) - - return PresignResponse(url=url, key=key) +# Backwards-compatibility shim – presign endpoint now lives in app.files.router +from app.files.router import router # noqa: F401 diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 2d89a57216..76880f88f6 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,231 +1,2 @@ -import uuid -from typing import Any - -from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import col, delete, func, select - -from app import crud -from app.api.deps import ( - CurrentUser, - SessionDep, - get_current_active_superuser, -) -from app.core.config import settings -from app.core.security import get_password_hash, verify_password -from app.models import ( - Item, - Message, - UpdatePassword, - User, - UserCreate, - UserPublic, - UserRegister, - UsersPublic, - UserUpdate, - UserUpdateMe, -) -from app.utils import generate_new_account_email, send_email - -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.get( - "/", - dependencies=[Depends(get_current_active_superuser)], - response_model=UsersPublic, -) -def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: - """ - Retrieve users. - """ - - count_statement = select(func.count()).select_from(User) - count = session.exec(count_statement).one() - - statement = ( - select(User).order_by(col(User.created_at).desc()).offset(skip).limit(limit) - ) - users = session.exec(statement).all() - - return UsersPublic(data=list(users), count=count) - - -@router.post( - "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic -) -def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: - """ - Create new user. - """ - user = crud.get_user_by_email(session=session, email=user_in.email) - if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system.", - ) - - user = crud.create_user(session=session, user_create=user_in) - if settings.emails_enabled and user_in.email: - email_data = generate_new_account_email( - email_to=user_in.email, username=user_in.email, password=user_in.password - ) - send_email( - email_to=user_in.email, - subject=email_data.subject, - html_content=email_data.html_content, - ) - return user - - -@router.patch("/me", response_model=UserPublic) -def update_user_me( - *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser -) -> Any: - """ - Update own user. - """ - - if user_in.email: - existing_user = crud.get_user_by_email(session=session, email=user_in.email) - if existing_user and existing_user.id != current_user.id: - raise HTTPException( - status_code=409, detail="User with this email already exists" - ) - user_data = user_in.model_dump(exclude_unset=True) - current_user.sqlmodel_update(user_data) - session.add(current_user) - session.commit() - session.refresh(current_user) - return current_user - - -@router.patch("/me/password", response_model=Message) -def update_password_me( - *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser -) -> Any: - """ - Update own password. - """ - verified, _ = verify_password(body.current_password, current_user.hashed_password) - if not verified: - raise HTTPException(status_code=400, detail="Incorrect password") - if body.current_password == body.new_password: - raise HTTPException( - status_code=400, detail="New password cannot be the same as the current one" - ) - hashed_password = get_password_hash(body.new_password) - current_user.hashed_password = hashed_password - session.add(current_user) - session.commit() - return Message(message="Password updated successfully") - - -@router.get("/me", response_model=UserPublic) -def read_user_me(current_user: CurrentUser) -> Any: - """ - Get current user. - """ - return current_user - - -@router.delete("/me", response_model=Message) -def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: - """ - Delete own user. - """ - if current_user.is_superuser: - raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" - ) - session.delete(current_user) - session.commit() - return Message(message="User deleted successfully") - - -@router.post("/signup", response_model=UserPublic) -def register_user(session: SessionDep, user_in: UserRegister) -> Any: - """ - Create new user without the need to be logged in. - """ - user = crud.get_user_by_email(session=session, email=user_in.email) - if user: - raise HTTPException( - status_code=400, - detail="The user with this email already exists in the system", - ) - user_create = UserCreate.model_validate(user_in) - user = crud.create_user(session=session, user_create=user_create) - return user - - -@router.get("/{user_id}", response_model=UserPublic) -def read_user_by_id( - user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser -) -> Any: - """ - Get a specific user by id. - """ - user = session.get(User, user_id) - if user == current_user: - return user - if not current_user.is_superuser: - raise HTTPException( - status_code=403, - detail="The user doesn't have enough privileges", - ) - if user is None: - raise HTTPException(status_code=404, detail="User not found") - return user - - -@router.patch( - "/{user_id}", - dependencies=[Depends(get_current_active_superuser)], - response_model=UserPublic, -) -def update_user( - *, - session: SessionDep, - user_id: uuid.UUID, - user_in: UserUpdate, -) -> Any: - """ - Update a user. - """ - - db_user = session.get(User, user_id) - if not db_user: - raise HTTPException( - status_code=404, - detail="The user with this id does not exist in the system", - ) - if user_in.email: - existing_user = crud.get_user_by_email(session=session, email=user_in.email) - if existing_user and existing_user.id != user_id: - raise HTTPException( - status_code=409, detail="User with this email already exists" - ) - - db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in) - return db_user - - -@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) -def delete_user( - session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID -) -> Message: - """ - Delete a user. - """ - user = session.get(User, user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if user == current_user: - raise HTTPException( - status_code=403, detail="Super users are not allowed to delete themselves" - ) - statement = delete(Item).where(col(Item.owner_id) == user_id) - session.exec(statement) - session.delete(user) - session.commit() - return Message(message="User deleted successfully") +# Backwards-compatibility shim – router now lives in app.users.router +from app.users.router import router # noqa: F401 diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..0c2f5e5ea6 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends from pydantic.networks import EmailStr -from app.api.deps import get_current_active_superuser -from app.models import Message -from app.utils import generate_test_email, send_email +from app.auth.dependencies import get_current_active_superuser +from app.users.schemas import Message +from app.users.utils import generate_test_email, send_email router = APIRouter(prefix="/utils", tags=["utils"]) diff --git a/backend/app/auth/constants.py b/backend/app/auth/constants.py new file mode 100644 index 0000000000..2e4806e733 --- /dev/null +++ b/backend/app/auth/constants.py @@ -0,0 +1 @@ +ACCESS_TOKEN_TYPE = "bearer" diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000000..6f80bd271e --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from app.users.models import User + +from collections.abc import Generator +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError +from pydantic import ValidationError +from sqlmodel import Session + +from app.auth.schemas import TokenPayload +from app.core import security +from app.core.config import settings +from app.core.db import engine + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/login/access-token" +) + + +def get_db() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session + + +SessionDep = Annotated[Session, Depends(get_db)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] + + +def get_current_user(session: SessionDep, token: TokenDep): + from app.users.models import User + + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = session.get(User, token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +def get_current_active_superuser(current_user: Annotated[object, Depends(get_current_user)]): + if not getattr(current_user, "is_superuser", False): + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user + + +# Runtime type alias – using object avoids circular import at module level. +# The actual return type of get_current_user is app.users.models.User. +CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/app/auth/exceptions.py b/backend/app/auth/exceptions.py new file mode 100644 index 0000000000..7cd5f0f52b --- /dev/null +++ b/backend/app/auth/exceptions.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, status + +CredentialsException = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", +) + +InactiveUserException = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user", +) + +InvalidTokenException = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid token", +) diff --git a/backend/app/auth/models.py b/backend/app/auth/models.py new file mode 100644 index 0000000000..cde931a809 --- /dev/null +++ b/backend/app/auth/models.py @@ -0,0 +1 @@ +# db models for auth (JWT tokens are stateless; no DB models required currently) diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py new file mode 100644 index 0000000000..9996d005b4 --- /dev/null +++ b/backend/app/auth/router.py @@ -0,0 +1,118 @@ +from datetime import timedelta +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2PasswordRequestForm + +from app.auth.dependencies import CurrentUser, SessionDep, get_current_active_superuser +from app.auth.schemas import NewPassword, Token +from app.auth.service import authenticate +from app.auth.utils import generate_password_reset_token, verify_password_reset_token +from app.core import security +from app.core.config import settings +from app.users.schemas import Message, UserPublic, UserUpdate +from app.users.service import get_user_by_email, update_user +from app.users.utils import generate_reset_password_email, send_email + +router = APIRouter(tags=["login"]) + + +@router.post("/login/access-token") +def login_access_token( + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = authenticate( + session=session, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user.id, expires_delta=access_token_expires + ) + ) + + +@router.post("/login/test-token", response_model=UserPublic) +def test_token(current_user: CurrentUser) -> Any: + """ + Test access token + """ + return current_user + + +@router.post("/password-recovery/{email}") +def recover_password(email: str, session: SessionDep) -> Message: + """ + Password Recovery + """ + user = get_user_by_email(session=session, email=email) + + if user: + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + send_email( + email_to=user.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message( + message="If that email is registered, we sent a password recovery link" + ) + + +@router.post("/reset-password/") +def reset_password(session: SessionDep, body: NewPassword) -> Message: + """ + Reset password + """ + email = verify_password_reset_token(token=body.token) + if not email: + raise HTTPException(status_code=400, detail="Invalid token") + user = get_user_by_email(session=session, email=email) + if not user: + raise HTTPException(status_code=400, detail="Invalid token") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + user_in_update = UserUpdate(password=body.new_password) + update_user( + session=session, + db_user=user, + user_in=user_in_update, + ) + return Message(message="Password updated successfully") + + +@router.post( + "/password-recovery-html-content/{email}", + dependencies=[Depends(get_current_active_superuser)], + response_class=HTMLResponse, +) +def recover_password_html_content(email: str, session: SessionDep) -> Any: + """ + HTML Content for Password Recovery + """ + user = get_user_by_email(session=session, email=email) + + if not user: + raise HTTPException( + status_code=404, + detail="The user with this username does not exist in the system.", + ) + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + + return HTMLResponse( + content=email_data.html_content, headers={"subject:": email_data.subject} + ) diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000000..17f9e6cc99 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,17 @@ +from sqlmodel import Field, SQLModel + + +# JSON payload containing access token +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +# Contents of JWT token +class TokenPayload(SQLModel): + sub: str | None = None + + +class NewPassword(SQLModel): + token: str + new_password: str = Field(min_length=8, max_length=128) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000000..2041523bbb --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,26 @@ +from sqlmodel import Session + +from app.core.security import verify_password +from app.users.models import User +from app.users.service import get_user_by_email + +# Dummy hash to use for timing attack prevention when user is not found. +# This is an Argon2 hash of a random password. +DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk" + + +def authenticate(*, session: Session, email: str, password: str) -> User | None: + db_user = get_user_by_email(session=session, email=email) + if not db_user: + # Prevent timing attacks by running password verification even when user doesn't exist + verify_password(password, DUMMY_HASH) + return None + verified, updated_password_hash = verify_password(password, db_user.hashed_password) + if not verified: + return None + if updated_password_hash: + db_user.hashed_password = updated_password_hash + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user diff --git a/backend/app/auth/utils.py b/backend/app/auth/utils.py new file mode 100644 index 0000000000..7f34da3232 --- /dev/null +++ b/backend/app/auth/utils.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from jwt.exceptions import InvalidTokenError + +from app.core import security +from app.core.config import settings + + +def generate_password_reset_token(email: str) -> str: + delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) + now = datetime.now(timezone.utc) + expires = now + delta + exp = expires.timestamp() + encoded_jwt = jwt.encode( + {"exp": exp, "nbf": now, "sub": email}, + settings.SECRET_KEY, + algorithm=security.ALGORITHM, + ) + return encoded_jwt + + +def verify_password_reset_token(token: str) -> str | None: + try: + decoded_token = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + return str(decoded_token["sub"]) + except InvalidTokenError: + return None diff --git a/backend/app/api/routes/image_parsers.py b/backend/app/aws/__init__.py similarity index 100% rename from backend/app/api/routes/image_parsers.py rename to backend/app/aws/__init__.py diff --git a/backend/app/aws/client.py b/backend/app/aws/client.py new file mode 100644 index 0000000000..7375f3f4f8 --- /dev/null +++ b/backend/app/aws/client.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import boto3 +from botocore.client import Config + +from app.aws.config import aws_settings + + +def get_s3_client(): + """Return a boto3 S3 client configured for S3-compatible endpoints (e.g., Cloudflare R2).""" + kwargs: dict = {} + if aws_settings.R2_ACCESS_KEY: + kwargs["aws_access_key_id"] = aws_settings.R2_ACCESS_KEY + if aws_settings.R2_SECRET_KEY: + kwargs["aws_secret_access_key"] = aws_settings.R2_SECRET_KEY + + endpoint = f"https://{aws_settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + client_config = Config(signature_version="s3v4") + + return boto3.client("s3", endpoint_url=endpoint, config=client_config, **kwargs) + + +def generate_presigned_put_url(key: str, bucket: str | None = None, expiration: int = 3600) -> str: + bucket = bucket or aws_settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured") + + client = get_s3_client() + params = {"Bucket": bucket, "Key": key} + url = client.generate_presigned_url( + ClientMethod="put_object", Params=params, ExpiresIn=expiration + ) + return url + + +def upload_file(key: str, data: bytes, bucket: str | None = None, content_type: str | None = None) -> dict: + bucket = bucket or aws_settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured") + + client = get_s3_client() + extra_args: dict = {} + if content_type: + extra_args["ContentType"] = content_type + + resp = client.put_object(Bucket=bucket, Key=key, Body=data, **extra_args) + return resp diff --git a/backend/app/aws/config.py b/backend/app/aws/config.py new file mode 100644 index 0000000000..5ebf5f62ae --- /dev/null +++ b/backend/app/aws/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AWSSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file="../.env", + env_ignore_empty=True, + extra="ignore", + ) + + R2_ACCESS_KEY: str | None = None + R2_SECRET_KEY: str | None = None + R2_BUCKET_NAME: str | None = None + R2_ACCOUNT_ID: str | None = None + + +aws_settings = AWSSettings() # type: ignore[call-arg] diff --git a/backend/app/aws/constants.py b/backend/app/aws/constants.py new file mode 100644 index 0000000000..0564a291b1 --- /dev/null +++ b/backend/app/aws/constants.py @@ -0,0 +1 @@ +DEFAULT_PRESIGN_EXPIRATION_SECONDS = 3600 diff --git a/backend/app/aws/exceptions.py b/backend/app/aws/exceptions.py new file mode 100644 index 0000000000..3ac668931e --- /dev/null +++ b/backend/app/aws/exceptions.py @@ -0,0 +1,6 @@ +from fastapi import HTTPException, status + +S3BucketNotConfiguredException = HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="S3 bucket not configured", +) diff --git a/backend/app/aws/schemas.py b/backend/app/aws/schemas.py new file mode 100644 index 0000000000..e455e9fa59 --- /dev/null +++ b/backend/app/aws/schemas.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class PresignRequest(BaseModel): + filename: str + content_type: str | None = None + bucket: str | None = None + + +class PresignResponse(BaseModel): + url: str + key: str diff --git a/backend/app/aws/utils.py b/backend/app/aws/utils.py new file mode 100644 index 0000000000..3540bdc231 --- /dev/null +++ b/backend/app/aws/utils.py @@ -0,0 +1 @@ +# AWS/S3 utility functions diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..cb953dddf4 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,25 +1,18 @@ from sqlmodel import Session, create_engine, select -from app import crud from app.core.config import settings -from app.models import User, UserCreate engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) - -# make sure all SQLModel models are imported (app.models) before initializing DB +# make sure all SQLModel models are imported before initializing DB # otherwise, SQLModel might fail to initialize relationships properly -# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 - - def init_db(session: Session) -> None: - # Tables should be created with Alembic migrations - # But if you don't want to use migrations, create - # the tables un-commenting the next lines - # from sqlmodel import SQLModel - - # This works because the models are already imported and registered from app.models - # SQLModel.metadata.create_all(engine) + # Import all models so SQLModel registers them + from app.files.models import File # noqa: F401 + from app.items.models import Item # noqa: F401 + from app.users.models import User + from app.users.schemas import UserCreate + from app.users.service import create_user user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) @@ -30,4 +23,4 @@ def init_db(session: Session) -> None: password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, ) - user = crud.create_user(session=session, user_create=user_in) + create_user(session=session, user_create=user_in) diff --git a/backend/app/crud.py b/backend/app/crud.py deleted file mode 100644 index cc628f7800..0000000000 --- a/backend/app/crud.py +++ /dev/null @@ -1,67 +0,0 @@ -import uuid -from typing import Any - -from sqlmodel import Session, select - -from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate - -def create_user(*, session: Session, user_create: UserCreate) -> User: - db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} - ) - session.add(db_obj) - session.commit() - session.refresh(db_obj) - return db_obj - - -def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: - user_data = user_in.model_dump(exclude_unset=True) - extra_data = {} - if "password" in user_data: - password = user_data["password"] - hashed_password = get_password_hash(password) - extra_data["hashed_password"] = hashed_password - db_user.sqlmodel_update(user_data, update=extra_data) - session.add(db_user) - session.commit() - session.refresh(db_user) - return db_user - - -def get_user_by_email(*, session: Session, email: str) -> User | None: - statement = select(User).where(User.email == email) - session_user = session.exec(statement).first() - return session_user - - -# Dummy hash to use for timing attack prevention when user is not found -# This is an Argon2 hash of a random password, used to ensure constant-time comparison -DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk" - - -def authenticate(*, session: Session, email: str, password: str) -> User | None: - db_user = get_user_by_email(session=session, email=email) - if not db_user: - # Prevent timing attacks by running password verification even when user doesn't exist - # This ensures the response time is similar whether or not the email exists - verify_password(password, DUMMY_HASH) - return None - verified, updated_password_hash = verify_password(password, db_user.hashed_password) - if not verified: - return None - if updated_password_hash: - db_user.hashed_password = updated_password_hash - session.add(db_user) - session.commit() - session.refresh(db_user) - return db_user - - -def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: - db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) - session.add(db_item) - session.commit() - session.refresh(db_item) - return db_item diff --git a/backend/app/api/routes/pdf_parsers.py b/backend/app/files/__init__.py similarity index 100% rename from backend/app/api/routes/pdf_parsers.py rename to backend/app/files/__init__.py diff --git a/backend/app/files/constants.py b/backend/app/files/constants.py new file mode 100644 index 0000000000..da595d14e7 --- /dev/null +++ b/backend/app/files/constants.py @@ -0,0 +1 @@ +MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB diff --git a/backend/app/files/dependencies.py b/backend/app/files/dependencies.py new file mode 100644 index 0000000000..96419242cb --- /dev/null +++ b/backend/app/files/dependencies.py @@ -0,0 +1,6 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) diff --git a/backend/app/files/exceptions.py b/backend/app/files/exceptions.py new file mode 100644 index 0000000000..5d2e8d6dc0 --- /dev/null +++ b/backend/app/files/exceptions.py @@ -0,0 +1,11 @@ +from fastapi import HTTPException, status + +FileNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="File not found", +) + +FileTooLargeException = HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail="File too large", +) diff --git a/backend/app/files/models.py b/backend/app/files/models.py new file mode 100644 index 0000000000..fa590ecbbe --- /dev/null +++ b/backend/app/files/models.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.users.models import User + + +def get_datetime_utc() -> datetime: + return datetime.now(timezone.utc) + + +class File(SQLModel, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + filename: str = Field(min_length=1, max_length=255) + content_type: str = Field(min_length=1, max_length=255) + size: int | None = None + url: str | None = None + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + owner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + owner: User | None = Relationship(back_populates="files") diff --git a/backend/app/files/router.py b/backend/app/files/router.py new file mode 100644 index 0000000000..8c9c2b288a --- /dev/null +++ b/backend/app/files/router.py @@ -0,0 +1,47 @@ +from fastapi import APIRouter, File, HTTPException, UploadFile + +from app.aws.client import upload_file +from app.aws.config import aws_settings +from app.aws.schemas import PresignRequest, PresignResponse + +router = APIRouter(prefix="/files", tags=["files"]) + + +@router.post("/") +def upload_file_endpoint( + file: UploadFile = File() # noqa: B008 +): + """ + Upload a file to R2/S3 storage. + """ + file_bytes = file.file.read() + response = upload_file( + key=file.filename or "upload", + data=file_bytes, + content_type=file.content_type, + ) + return { + "filename": file.filename, + "content_type": file.content_type, + "size": len(file_bytes), + "s3_response": response, + } + + +@router.post("/presign", response_model=PresignResponse) +def presign_upload(req: PresignRequest): + """ + Generate a presigned PUT URL for direct client uploads. + """ + from app.aws.client import generate_presigned_put_url + + if not aws_settings.R2_BUCKET_NAME and not req.bucket: + raise HTTPException(status_code=500, detail="S3 bucket not configured") + + key = req.filename + try: + url = generate_presigned_put_url(key=key, bucket=req.bucket, expiration=3600) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + return PresignResponse(url=url, key=key) diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py new file mode 100644 index 0000000000..21561cfcff --- /dev/null +++ b/backend/app/files/schemas.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from sqlmodel import Field, SQLModel + + +class FileBase(SQLModel): + filename: str = Field(min_length=1, max_length=255) + content_type: str = Field(min_length=1, max_length=255) + size: int | None = None + + +class FileCreate(FileBase): + url: str | None = None + + +class FilePublic(FileBase): + id: uuid.UUID + created_at: datetime | None = None + owner_id: uuid.UUID + + +class FilesPublic(SQLModel): + data: list[FilePublic] + count: int diff --git a/backend/app/files/service.py b/backend/app/files/service.py new file mode 100644 index 0000000000..0dedecaa57 --- /dev/null +++ b/backend/app/files/service.py @@ -0,0 +1,14 @@ +import uuid + +from sqlmodel import Session + +from app.files.models import File +from app.files.schemas import FileCreate + + +def create_file(*, session: Session, file_in: FileCreate, owner_id: uuid.UUID) -> File: + db_file = File.model_validate(file_in, update={"owner_id": owner_id}) + session.add(db_file) + session.commit() + session.refresh(db_file) + return db_file diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py new file mode 100644 index 0000000000..503d2fe00f --- /dev/null +++ b/backend/app/files/utils.py @@ -0,0 +1 @@ +# File-related utility functions diff --git a/backend/app/helpers/s3.py b/backend/app/helpers/s3.py index 0caa0b14cc..2302f88629 100644 --- a/backend/app/helpers/s3.py +++ b/backend/app/helpers/s3.py @@ -1,46 +1,2 @@ -from __future__ import annotations - -import boto3 -from botocore.client import Config - -from app.core.config import settings - -def _get_s3_client(): - """Return a boto3 S3 client configured for S3-compatible endpoints (e.g., Cloudflare R2).""" - kwargs: dict = {} - if settings.R2_ACCESS_KEY: - kwargs["aws_access_key_id"] = settings.R2_ACCESS_KEY - if settings.R2_SECRET_KEY: - kwargs["aws_secret_access_key"] = settings.R2_SECRET_KEY - - endpoint = f"https://{settings.R2_ACCOUNT_ID}.r2.cloudflarestorage.com" - client_config = Config(signature_version="s3v4") - - return boto3.client("s3", endpoint_url=endpoint, config=client_config, **kwargs) - - -def generate_presigned_put_url(key: str, bucket: str | None = None, expiration: int = 3600) -> str: - bucket = bucket or settings.R2_BUCKET_NAME - if not bucket: - raise RuntimeError("S3 bucket not configured (S3_BUCKET)") - - client = _get_s3_client() - params = {"Bucket": bucket, "Key": key} - url = client.generate_presigned_url( - ClientMethod="put_object", Params=params, ExpiresIn=expiration - ) - return url - - -def upload_r2_file(key: str, data: bytes, bucket: str | None = None, content_type: str | None = None) -> dict: - bucket = bucket or settings.R2_BUCKET_NAME - if not bucket: - raise RuntimeError("S3 bucket not configured (S3_BUCKET)") - - client = _get_s3_client() - extra_args: dict = {} - if content_type: - extra_args["ContentType"] = content_type - - resp = client.put_object(Bucket=bucket, Key=key, Body=data, **extra_args) - return resp +# Backwards-compatibility shim – S3 client now lives in app.aws.client +from app.aws.client import generate_presigned_put_url, get_s3_client, upload_file as upload_r2_file # noqa: F401 diff --git a/backend/app/items/__init__.py b/backend/app/items/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/items/constants.py b/backend/app/items/constants.py new file mode 100644 index 0000000000..c13e8e12c3 --- /dev/null +++ b/backend/app/items/constants.py @@ -0,0 +1 @@ +MAX_ITEMS_PER_PAGE = 100 diff --git a/backend/app/items/dependencies.py b/backend/app/items/dependencies.py new file mode 100644 index 0000000000..96419242cb --- /dev/null +++ b/backend/app/items/dependencies.py @@ -0,0 +1,6 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) diff --git a/backend/app/items/exceptions.py b/backend/app/items/exceptions.py new file mode 100644 index 0000000000..8a008012b7 --- /dev/null +++ b/backend/app/items/exceptions.py @@ -0,0 +1,11 @@ +from fastapi import HTTPException, status + +ItemNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Item not found", +) + +InsufficientPermissionsException = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", +) diff --git a/backend/app/items/models.py b/backend/app/items/models.py new file mode 100644 index 0000000000..ca363cddd0 --- /dev/null +++ b/backend/app/items/models.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime +from sqlmodel import Field, Relationship, SQLModel + +if TYPE_CHECKING: + from app.users.models import User + + +def get_datetime_utc() -> datetime: + return datetime.now(timezone.utc) + + +class Item(SQLModel, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + owner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + owner: User | None = Relationship(back_populates="items") diff --git a/backend/app/items/router.py b/backend/app/items/router.py new file mode 100644 index 0000000000..014cfa4e91 --- /dev/null +++ b/backend/app/items/router.py @@ -0,0 +1,108 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import col, func, select + +from app.auth.dependencies import CurrentUser, SessionDep +from app.items.models import Item +from app.items.schemas import ItemCreate, ItemPublic, ItemsPublic, ItemUpdate +from app.items.service import create_item, update_item +from app.users.schemas import Message + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("/", response_model=ItemsPublic) +def read_items( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve items. + """ + if current_user.is_superuser: + count_statement = select(func.count()).select_from(Item) + count = 1#session.exec(count_statement).one() + statement = ( + select(Item).order_by(col(Item.created_at).desc()).offset(skip).limit(limit) + ) + items = session.exec(statement).all() + else: + # count_statement = ( + # select(func.count()) + # .select_from(Item) + # .where(Item.owner_id == current_user.id) + # ) + # count = session.exec(count_statement).one() + # statement = ( + # select(Item) + # .where(Item.owner_id == current_user.id) + # .order_by(col(Item.created_at).desc()) + # .offset(skip) + # .limit(limit) + # ) + # items = session.exec(statement).all() + pass + + return ItemsPublic(data=[], count=1) # ty:ignore[invalid-argument-type] + + +@router.get("/{id}", response_model=ItemPublic) +def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get item by ID. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + return item + + +@router.post("/", response_model=ItemPublic) +def create_item_endpoint( + *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate +) -> Any: + """ + Create new item. + """ + item = create_item(session=session, item_in=item_in, owner_id=current_user.id) + return item + + +@router.put("/{id}", response_model=ItemPublic) +def update_item_endpoint( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + item_in: ItemUpdate, +) -> Any: + """ + Update an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + item = update_item(session=session, db_item=item, item_in=item_in) + return item + + +@router.delete("/{id}") +def delete_item( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.user_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(item) + session.commit() + return Message(message="Item deleted successfully") diff --git a/backend/app/items/schemas.py b/backend/app/items/schemas.py new file mode 100644 index 0000000000..a0106b8479 --- /dev/null +++ b/backend/app/items/schemas.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlmodel import Field, SQLModel + + +# Shared properties +class ItemBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + + +# Properties to receive on item creation +class ItemCreate(ItemBase): + pass + + +# Properties to receive on item update +class ItemUpdate(ItemBase): + title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore[assignment] + + +# Properties to return via API, id is always required +class ItemPublic(ItemBase): + id: uuid.UUID + owner_id: uuid.UUID + created_at: datetime | None = None + + +class ItemsPublic(SQLModel): + data: list[ItemPublic] + count: int diff --git a/backend/app/items/service.py b/backend/app/items/service.py new file mode 100644 index 0000000000..2afe27b42d --- /dev/null +++ b/backend/app/items/service.py @@ -0,0 +1,23 @@ +import uuid + +from sqlmodel import Session + +from app.items.models import Item +from app.items.schemas import ItemCreate, ItemUpdate + + +def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: + db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item + + +def update_item(*, session: Session, db_item: Item, item_in: ItemUpdate) -> Item: + update_dict = item_in.model_dump(exclude_unset=True) + db_item.sqlmodel_update(update_dict) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item diff --git a/backend/app/items/utils.py b/backend/app/items/utils.py new file mode 100644 index 0000000000..528bfb7b68 --- /dev/null +++ b/backend/app/items/utils.py @@ -0,0 +1 @@ +# Item-related utility functions diff --git a/backend/app/models.py b/backend/app/models.py index e02d357768..b5856cf5a8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,161 +1,37 @@ -import uuid -from datetime import datetime, timezone - -from pydantic import EmailStr -from sqlalchemy import DateTime -from sqlmodel import Field, Relationship, SQLModel - - -def get_datetime_utc() -> datetime: - return datetime.now(timezone.utc) - - -# Shared properties -class UserBase(SQLModel): - email: EmailStr = Field(unique=True, index=True, max_length=255) - is_active: bool = True - is_superuser: bool = False - full_name: str | None = Field(default=None, max_length=255) - - -# Properties to receive via API on creation -class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=128) - - -class UserRegister(SQLModel): - email: EmailStr = Field(max_length=255) - password: str = Field(min_length=8, max_length=128) - full_name: str | None = Field(default=None, max_length=255) - - -# Properties to receive via API on update, all are optional -class UserUpdate(UserBase): - email: EmailStr | None = Field(default=None, max_length=255) # type: ignore - password: str | None = Field(default=None, min_length=8, max_length=128) - - -class UserUpdateMe(SQLModel): - full_name: str | None = Field(default=None, max_length=255) - email: EmailStr | None = Field(default=None, max_length=255) - - -class UpdatePassword(SQLModel): - current_password: str = Field(min_length=8, max_length=128) - new_password: str = Field(min_length=8, max_length=128) - - -# Database model, database table inferred from class name -class User(UserBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - hashed_password: str - created_at: datetime | None = Field( - default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore - ) - items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) - files: list["File"] = Relationship(back_populates="owner", cascade_delete=True) - - -# Properties to return via API, id is always required -class UserPublic(UserBase): - id: uuid.UUID - created_at: datetime | None = None - - -class UsersPublic(SQLModel): - data: list[UserPublic] - count: int - - -# Shared properties -class ItemBase(SQLModel): - title: str = Field(min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) - - -# Properties to receive on item creation -class ItemCreate(ItemBase): - pass - - -# Properties to receive on item update -class ItemUpdate(ItemBase): - title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore - - -# Database model, database table inferred from class name -class Item(ItemBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - created_at: datetime | None = Field( - default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore - ) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" - ) - owner: User | None = Relationship(back_populates="items") - - -# Properties to return via API, id is always required -class ItemPublic(ItemBase): - id: uuid.UUID - owner_id: uuid.UUID - created_at: datetime | None = None - - -class ItemsPublic(SQLModel): - data: list[ItemPublic] - count: int - - -# Generic message -class Message(SQLModel): - message: str - - -# JSON payload containing access token -class Token(SQLModel): - access_token: str - token_type: str = "bearer" - - -# Contents of JWT token -class TokenPayload(SQLModel): - sub: str | None = None - - -class NewPassword(SQLModel): - token: str - new_password: str = Field(min_length=8, max_length=128) - - -# Files -class FileBase(SQLModel): - filename: str = Field(min_length=1, max_length=255) - content_type: str = Field(min_length=1, max_length=255) - size: int | None = None - -class FileCreate(FileBase): - url: str | None = None - pass - -class FilePublic(FileBase): - id: uuid.UUID - created_at: datetime | None = None - owner_id: uuid.UUID - -class FilesPublic(SQLModel): - data: list[FilePublic] - count: int - -class File(FileBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - created_at: datetime | None = Field( - default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore - ) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" - ) - owner: User | None = Relationship(back_populates="files") +""" +Backwards-compatibility shim. +All models now live in the per-domain model modules: + - app.users.models / app.users.schemas + - app.items.models / app.items.schemas + - app.files.models / app.files.schemas + - app.auth.schemas +""" +from sqlmodel import SQLModel # noqa: F401 + +from app.auth.schemas import NewPassword, Token, TokenPayload # noqa: F401 +from app.files.models import File # noqa: F401 +from app.files.schemas import ( # noqa: F401 + FileBase, + FileCreate, + FilePublic, + FilesPublic, +) +from app.items.models import Item # noqa: F401 +from app.items.schemas import ( # noqa: F401 + ItemBase, + ItemCreate, + ItemPublic, + ItemsPublic, + ItemUpdate, +) +from app.users.models import User # noqa: F401 +from app.users.schemas import ( # noqa: F401 + Message, + UpdatePassword, + UserCreate, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) diff --git a/backend/app/users/__init__.py b/backend/app/users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/users/constants.py b/backend/app/users/constants.py new file mode 100644 index 0000000000..c8b1f298c9 --- /dev/null +++ b/backend/app/users/constants.py @@ -0,0 +1 @@ +MAX_USERS_PER_PAGE = 100 diff --git a/backend/app/users/dependencies.py b/backend/app/users/dependencies.py new file mode 100644 index 0000000000..0023a6cacf --- /dev/null +++ b/backend/app/users/dependencies.py @@ -0,0 +1,8 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + TokenDep, + get_current_active_superuser, + get_current_user, + get_db, +) diff --git a/backend/app/users/exceptions.py b/backend/app/users/exceptions.py new file mode 100644 index 0000000000..ac437a9e44 --- /dev/null +++ b/backend/app/users/exceptions.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, status + +UserNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", +) + +UserAlreadyExistsException = HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this email already exists in the system.", +) + +InsufficientPrivilegesException = HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", +) diff --git a/backend/app/users/models.py b/backend/app/users/models.py new file mode 100644 index 0000000000..dfc347703a --- /dev/null +++ b/backend/app/users/models.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from pydantic import EmailStr +from sqlalchemy import DateTime +from sqlmodel import Field, Relationship, SQLModel + + +def get_datetime_utc() -> datetime: + return datetime.now(timezone.utc) + + +class UserBase(SQLModel): + email: EmailStr = Field(unique=True, index=True, max_length=255) + is_active: bool = True + is_superuser: bool = False + full_name: str | None = Field(default=None, max_length=255) + + +class User(UserBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + hashed_password: str + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + items: list[Item] = Relationship(back_populates="owner", cascade_delete=True) # noqa: F821 + files: list[File] = Relationship(back_populates="owner", cascade_delete=True) # noqa: F821 + + +# These imports MUST come after the User class definition so SQLModel +# can resolve the forward references 'Item' and 'File' at mapper init time. +from app.files.models import File # noqa: E402, F401 +from app.items.models import Item # noqa: E402, F401 + +User.model_rebuild() diff --git a/backend/app/users/router.py b/backend/app/users/router.py new file mode 100644 index 0000000000..35642bf6db --- /dev/null +++ b/backend/app/users/router.py @@ -0,0 +1,225 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import col, delete, func, select + +from app.auth.dependencies import CurrentUser, SessionDep, get_current_active_superuser +from app.core.config import settings +from app.core.security import get_password_hash, verify_password +from app.users.models import User +from app.users.schemas import ( + Message, + UpdatePassword, + UserCreate, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) +from app.users.service import create_user, get_user_by_email, update_user +from app.users.utils import generate_new_account_email, send_email + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=UsersPublic, +) +def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + """ + Retrieve users. + """ + count_statement = select(func.count()).select_from(User) + count = session.exec(count_statement).one() + + statement = ( + select(User).order_by(col(User.created_at).desc()).offset(skip).limit(limit) + ) + users = session.exec(statement).all() + + return UsersPublic(data=users, count=count) # ty:ignore[invalid-argument-type] + + +@router.post( + "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic +) +def create_user_endpoint(*, session: SessionDep, user_in: UserCreate) -> Any: + """ + Create new user. + """ + user = get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system.", + ) + + user = create_user(session=session, user_create=user_in) + if settings.emails_enabled and user_in.email: + email_data = generate_new_account_email( + email_to=user_in.email, username=user_in.email, password=user_in.password # type: ignore[arg-type] + ) + send_email( + email_to=user_in.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return user + + +@router.patch("/me", response_model=UserPublic) +def update_user_me( + *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser +) -> Any: + """ + Update own user. + """ + if user_in.email: + existing_user = get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != current_user.id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + user_data = user_in.model_dump(exclude_unset=True) + current_user.sqlmodel_update(user_data) + session.add(current_user) + session.commit() + session.refresh(current_user) + return current_user + + +@router.patch("/me/password", response_model=Message) +def update_password_me( + *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser +) -> Any: + """ + Update own password. + """ + verified, _ = verify_password(body.current_password, current_user.hashed_password) + if not verified: + raise HTTPException(status_code=400, detail="Incorrect password") + if body.current_password == body.new_password: + raise HTTPException( + status_code=400, detail="New password cannot be the same as the current one" + ) + hashed_password = get_password_hash(body.new_password) + current_user.hashed_password = hashed_password + session.add(current_user) + session.commit() + return Message(message="Password updated successfully") + + +@router.get("/me", response_model=UserPublic) +def read_user_me(current_user: CurrentUser) -> Any: + """ + Get current user. + """ + return current_user + + +@router.delete("/me", response_model=Message) +def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: + """ + Delete own user. + """ + if current_user.is_superuser: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + session.delete(current_user) + session.commit() + return Message(message="User deleted successfully") + + +@router.post("/signup", response_model=UserPublic) +def register_user(session: SessionDep, user_in: UserRegister) -> Any: + """ + Create new user without the need to be logged in. + """ + user = get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system", + ) + user_create = UserCreate.model_validate(user_in) + user = create_user(session=session, user_create=user_create) + return user + + +@router.get("/{user_id}", response_model=UserPublic) +def read_user_by_id( + user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser +) -> Any: + """ + Get a specific user by id. + """ + user = session.get(User, user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="The user doesn't have enough privileges", + ) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch( + "/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def update_user_endpoint( + *, + session: SessionDep, + user_id: uuid.UUID, + user_in: UserUpdate, +) -> Any: + """ + Update a user. + """ + db_user = session.get(User, user_id) + if not db_user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + if user_in.email: + existing_user = get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != user_id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + + db_user = update_user(session=session, db_user=db_user, user_in=user_in) + return db_user + + +@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) +def delete_user( + session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID +) -> Message: + """ + Delete a user. + """ + from app.items.models import Item + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user == current_user: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + statement = delete(Item).where(col(Item.owner_id) == user_id) + session.exec(statement) + session.delete(user) + session.commit() + return Message(message="User deleted successfully") diff --git a/backend/app/users/schemas.py b/backend/app/users/schemas.py new file mode 100644 index 0000000000..5e50a3f4b9 --- /dev/null +++ b/backend/app/users/schemas.py @@ -0,0 +1,59 @@ +import uuid +from datetime import datetime + +from pydantic import EmailStr +from sqlmodel import Field, SQLModel + + +# Properties to receive via API on creation +class UserCreate(SQLModel): + email: EmailStr = Field(max_length=255) + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + is_active: bool = True + is_superuser: bool = False + + +class UserRegister(SQLModel): + email: EmailStr = Field(max_length=255) + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + + +# Properties to receive via API on update, all are optional +class UserUpdate(SQLModel): + email: EmailStr | None = Field(default=None, max_length=255) + password: str | None = Field(default=None, min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + is_active: bool | None = None + is_superuser: bool | None = None + + +class UserUpdateMe(SQLModel): + full_name: str | None = Field(default=None, max_length=255) + email: EmailStr | None = Field(default=None, max_length=255) + + +class UpdatePassword(SQLModel): + current_password: str = Field(min_length=8, max_length=128) + new_password: str = Field(min_length=8, max_length=128) + + +# Properties to return via API, id is always required +class UserPublic(SQLModel): + id: uuid.UUID + email: EmailStr + is_active: bool = True + is_superuser: bool = False + full_name: str | None = None + created_at: datetime | None = None + + +class UsersPublic(SQLModel): + data: list[UserPublic] + count: int + + +# Generic message +class Message(SQLModel): + message: str diff --git a/backend/app/users/service.py b/backend/app/users/service.py new file mode 100644 index 0000000000..7572871470 --- /dev/null +++ b/backend/app/users/service.py @@ -0,0 +1,37 @@ +from typing import Any + +from sqlmodel import Session, select + +from app.core.security import get_password_hash +from app.users.models import User +from app.users.schemas import UserCreate, UserUpdate + + +def create_user(*, session: Session, user_create: UserCreate) -> User: + db_obj = User.model_validate( + user_create, update={"hashed_password": get_password_hash(user_create.password)} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: + user_data = user_in.model_dump(exclude_unset=True) + extra_data = {} + if "password" in user_data: + password = user_data["password"] + hashed_password = get_password_hash(password) + extra_data["hashed_password"] = hashed_password + db_user.sqlmodel_update(user_data, update=extra_data) + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +def get_user_by_email(*, session: Session, email: str) -> User | None: + statement = select(User).where(User.email == email) + session_user = session.exec(statement).first() + return session_user diff --git a/backend/app/users/utils.py b/backend/app/users/utils.py new file mode 100644 index 0000000000..94d2ba3701 --- /dev/null +++ b/backend/app/users/utils.py @@ -0,0 +1,96 @@ +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import emails # type: ignore +from jinja2 import Template + +from app.core.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@dataclass +class EmailData: + html_content: str + subject: str + + +def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: + template_str = ( + Path(__file__).parent.parent / "email-templates" / "build" / template_name + ).read_text() + html_content = Template(template_str).render(context) + return html_content + + +def send_email( + *, + email_to: str, + subject: str = "", + html_content: str = "", +) -> None: + assert settings.emails_enabled, "no provided configuration for email variables" + message = emails.Message( + subject=subject, + html=html_content, + mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), + ) + smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} + if settings.SMTP_TLS: + smtp_options["tls"] = True + elif settings.SMTP_SSL: + smtp_options["ssl"] = True + if settings.SMTP_USER: + smtp_options["user"] = settings.SMTP_USER + if settings.SMTP_PASSWORD: + smtp_options["password"] = settings.SMTP_PASSWORD + response = message.send(to=email_to, smtp=smtp_options) + logger.info(f"send email result: {response}") + + +def generate_test_email(email_to: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Test email" + html_content = render_email_template( + template_name="test_email.html", + context={"project_name": settings.PROJECT_NAME, "email": email_to}, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Password recovery for user {email}" + link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" + html_content = render_email_template( + template_name="reset_password.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": email, + "email": email_to, + "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, + "link": link, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_new_account_email( + email_to: str, username: str, password: str +) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - New account for user {username}" + html_content = render_email_template( + template_name="new_account.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": username, + "password": password, + "email": email_to, + "link": settings.FRONTEND_HOST, + }, + ) + return EmailData(html_content=html_content, subject=subject) \ No newline at end of file diff --git a/backend/app/utils.py b/backend/app/utils.py index ac029f6342..961de45686 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -1,123 +1,23 @@ -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any - -import emails # type: ignore -import jwt -from jinja2 import Template -from jwt.exceptions import InvalidTokenError - -from app.core import security -from app.core.config import settings - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -@dataclass -class EmailData: - html_content: str - subject: str - - -def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: - template_str = ( - Path(__file__).parent / "email-templates" / "build" / template_name - ).read_text() - html_content = Template(template_str).render(context) - return html_content - - -def send_email( - *, - email_to: str, - subject: str = "", - html_content: str = "", -) -> None: - assert settings.emails_enabled, "no provided configuration for email variables" - message = emails.Message( - subject=subject, - html=html_content, - mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), - ) - smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} - if settings.SMTP_TLS: - smtp_options["tls"] = True - elif settings.SMTP_SSL: - smtp_options["ssl"] = True - if settings.SMTP_USER: - smtp_options["user"] = settings.SMTP_USER - if settings.SMTP_PASSWORD: - smtp_options["password"] = settings.SMTP_PASSWORD - response = message.send(to=email_to, smtp=smtp_options) - logger.info(f"send email result: {response}") - - -def generate_test_email(email_to: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Test email" - html_content = render_email_template( - template_name="test_email.html", - context={"project_name": settings.PROJECT_NAME, "email": email_to}, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - Password recovery for user {email}" - link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" - html_content = render_email_template( - template_name="reset_password.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": email, - "email": email_to, - "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, - "link": link, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_new_account_email( - email_to: str, username: str, password: str -) -> EmailData: - project_name = settings.PROJECT_NAME - subject = f"{project_name} - New account for user {username}" - html_content = render_email_template( - template_name="new_account.html", - context={ - "project_name": settings.PROJECT_NAME, - "username": username, - "password": password, - "email": email_to, - "link": settings.FRONTEND_HOST, - }, - ) - return EmailData(html_content=html_content, subject=subject) - - -def generate_password_reset_token(email: str) -> str: - delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) - now = datetime.now(timezone.utc) - expires = now + delta - exp = expires.timestamp() - encoded_jwt = jwt.encode( - {"exp": exp, "nbf": now, "sub": email}, - settings.SECRET_KEY, - algorithm=security.ALGORITHM, - ) - return encoded_jwt - - -def verify_password_reset_token(token: str) -> str | None: - try: - decoded_token = jwt.decode( - token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] - ) - return str(decoded_token["sub"]) - except InvalidTokenError: - return None +""" +Backwards-compatibility shim. +All utilities now live in the per-domain utils modules: + - app.users.utils (email helpers) + - app.auth.utils (password reset token helpers) +""" +from datetime import datetime, timezone + +from app.auth.utils import ( # noqa: F401 + generate_password_reset_token, + verify_password_reset_token, +) +from app.users.utils import ( # noqa: F401 + EmailData, + generate_new_account_email, + generate_reset_password_email, + generate_test_email, + send_email, +) + + +def get_datetime_utc() -> datetime: + return datetime.now(timezone.utc) \ No newline at end of file From 2ad040addc93f15d827990089314e1e35c6ab3a6 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Wed, 25 Mar 2026 00:50:54 +0700 Subject: [PATCH 03/30] update relationships --- .../versions/1b631ae321a9_edit_model.py | 97 +++++++++++++++++++ .../b6a0bfbb9e6d_remove_relationship.py | 29 ++++++ backend/app/files/models.py | 23 ++--- backend/app/items/models.py | 23 ++--- backend/app/users/models.py | 25 +---- backend/app/users/router.py | 2 +- backend/tests/api/routes/test_items.py | 4 +- 7 files changed, 148 insertions(+), 55 deletions(-) create mode 100644 backend/app/alembic/versions/1b631ae321a9_edit_model.py create mode 100644 backend/app/alembic/versions/b6a0bfbb9e6d_remove_relationship.py diff --git a/backend/app/alembic/versions/1b631ae321a9_edit_model.py b/backend/app/alembic/versions/1b631ae321a9_edit_model.py new file mode 100644 index 0000000000..47e735d0bd --- /dev/null +++ b/backend/app/alembic/versions/1b631ae321a9_edit_model.py @@ -0,0 +1,97 @@ +"""edit-model + +Revision ID: 1b631ae321a9 +Revises: b6a0bfbb9e6d +Create Date: 2026-03-25 00:47:51.797713 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '1b631ae321a9' +down_revision = 'b6a0bfbb9e6d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_superuser', sa.Boolean(), nullable=False), + sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('files', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('size', sa.Integer(), nullable=True), + sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('items', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.drop_table('file') + op.drop_table('item') + op.drop_index(op.f('ix_user_email'), table_name='user') + op.drop_table('user') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('email', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('is_superuser', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('full_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('hashed_password', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('user_pkey')) + ) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + op.create_table('item', + sa.Column('description', sa.VARCHAR(length=255), autoincrement=False, nullable=True), + sa.Column('title', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('owner_id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], name=op.f('item_owner_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('item_pkey')) + ) + op.create_table('file', + sa.Column('filename', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('content_type', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('size', sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column('url', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('id', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('owner_id', sa.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id'], name=op.f('file_owner_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('file_pkey')) + ) + op.drop_table('items') + op.drop_table('files') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/b6a0bfbb9e6d_remove_relationship.py b/backend/app/alembic/versions/b6a0bfbb9e6d_remove_relationship.py new file mode 100644 index 0000000000..25ebb148e9 --- /dev/null +++ b/backend/app/alembic/versions/b6a0bfbb9e6d_remove_relationship.py @@ -0,0 +1,29 @@ +"""remove-relationship + +Revision ID: b6a0bfbb9e6d +Revises: c6971f55aa53 +Create Date: 2026-03-25 00:45:07.598264 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'b6a0bfbb9e6d' +down_revision = 'c6971f55aa53' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/files/models.py b/backend/app/files/models.py index fa590ecbbe..572aa5cc9f 100644 --- a/backend/app/files/models.py +++ b/backend/app/files/models.py @@ -1,21 +1,12 @@ from __future__ import annotations - +from app.utils import get_datetime_utc import uuid -from datetime import datetime, timezone -from typing import TYPE_CHECKING - +from datetime import datetime from sqlalchemy import DateTime -from sqlmodel import Field, Relationship, SQLModel - -if TYPE_CHECKING: - from app.users.models import User - - -def get_datetime_utc() -> datetime: - return datetime.now(timezone.utc) - +from sqlmodel import Field, SQLModel class File(SQLModel, table=True): + __tablename__ = "files" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) filename: str = Field(min_length=1, max_length=255) content_type: str = Field(min_length=1, max_length=255) @@ -25,7 +16,7 @@ class File(SQLModel, table=True): default_factory=get_datetime_utc, sa_type=DateTime(timezone=True), # type: ignore[call-arg] ) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" + user_id: uuid.UUID = Field( + foreign_key="users.id", nullable=False, ondelete="CASCADE" ) - owner: User | None = Relationship(back_populates="files") + # owner: User | None = Relationship(back_populates="files") diff --git a/backend/app/items/models.py b/backend/app/items/models.py index ca363cddd0..13a8e3e1b3 100644 --- a/backend/app/items/models.py +++ b/backend/app/items/models.py @@ -1,21 +1,12 @@ from __future__ import annotations - +from app.utils import get_datetime_utc import uuid -from datetime import datetime, timezone -from typing import TYPE_CHECKING - +from datetime import datetime from sqlalchemy import DateTime -from sqlmodel import Field, Relationship, SQLModel - -if TYPE_CHECKING: - from app.users.models import User - - -def get_datetime_utc() -> datetime: - return datetime.now(timezone.utc) - +from sqlmodel import Field, SQLModel class Item(SQLModel, table=True): + __tablename__ = "items" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(min_length=1, max_length=255) description: str | None = Field(default=None, max_length=255) @@ -23,7 +14,7 @@ class Item(SQLModel, table=True): default_factory=get_datetime_utc, sa_type=DateTime(timezone=True), # type: ignore[call-arg] ) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" + user_id: uuid.UUID = Field( + foreign_key="users.id", nullable=False, ondelete="CASCADE" ) - owner: User | None = Relationship(back_populates="items") + # owner: User | None = Relationship(back_populates="items") diff --git a/backend/app/users/models.py b/backend/app/users/models.py index dfc347703a..bc1a8f27b0 100644 --- a/backend/app/users/models.py +++ b/backend/app/users/models.py @@ -1,16 +1,10 @@ from __future__ import annotations - +from app.utils import get_datetime_utc import uuid -from datetime import datetime, timezone - +from datetime import datetime from pydantic import EmailStr from sqlalchemy import DateTime -from sqlmodel import Field, Relationship, SQLModel - - -def get_datetime_utc() -> datetime: - return datetime.now(timezone.utc) - +from sqlmodel import Field, SQLModel class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True, max_length=255) @@ -20,19 +14,10 @@ class UserBase(SQLModel): class User(UserBase, table=True): + __tablename__ = "users" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str created_at: datetime | None = Field( default_factory=get_datetime_utc, sa_type=DateTime(timezone=True), # type: ignore[call-arg] - ) - items: list[Item] = Relationship(back_populates="owner", cascade_delete=True) # noqa: F821 - files: list[File] = Relationship(back_populates="owner", cascade_delete=True) # noqa: F821 - - -# These imports MUST come after the User class definition so SQLModel -# can resolve the forward references 'Item' and 'File' at mapper init time. -from app.files.models import File # noqa: E402, F401 -from app.items.models import Item # noqa: E402, F401 - -User.model_rebuild() + ) \ No newline at end of file diff --git a/backend/app/users/router.py b/backend/app/users/router.py index 35642bf6db..b07533b83e 100644 --- a/backend/app/users/router.py +++ b/backend/app/users/router.py @@ -218,7 +218,7 @@ def delete_user( raise HTTPException( status_code=403, detail="Super users are not allowed to delete themselves" ) - statement = delete(Item).where(col(Item.owner_id) == user_id) + statement = delete(Item).where(col(Item.user_id) == user_id) session.exec(statement) session.delete(user) session.commit() diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py index 3e82cd0134..91cac0e8bc 100644 --- a/backend/tests/api/routes/test_items.py +++ b/backend/tests/api/routes/test_items.py @@ -37,7 +37,7 @@ def test_read_item( assert content["title"] == item.title assert content["description"] == item.description assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) + assert content["owner_id"] == str(item.user_id) def test_read_item_not_found( @@ -94,7 +94,7 @@ def test_update_item( assert content["title"] == data["title"] assert content["description"] == data["description"] assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) + assert content["owner_id"] == str(item.user_id) def test_update_item_not_found( From ced573c0064d33a6f5ea0c45b9560d67a6040932 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Wed, 25 Mar 2026 02:03:26 +0700 Subject: [PATCH 04/30] add home page --- .../b28ffd90-d4a86d43cefa197b56c330cba1429e4d | 9 + frontend/src/components/BankTypeSelect.tsx | 51 +++ frontend/src/components/FileHistoryTable.tsx | 92 +++++ .../src/components/FileUploadDropzone.tsx | 110 ++++++ frontend/src/components/Navbar.tsx | 50 +++ frontend/src/components/PricingCard.tsx | 49 +++ frontend/src/components/StatusBadge.tsx | 47 +++ frontend/src/components/ui/empty.tsx | 103 ++++++ frontend/src/components/ui/spinner.tsx | 15 + frontend/src/lib/mock-data.ts | 96 ++++++ frontend/src/routeTree.gen.ts | 92 ++++- frontend/src/routes/_public.tsx | 15 + frontend/src/routes/_public/index.tsx | 319 ++++++++++++++++++ frontend/src/routes/_public/pricing.tsx | 304 +++++++++++++++++ frontend/src/routes/_public/trial.tsx | 149 ++++++++ frontend/src/routes/login.tsx | 2 +- 16 files changed, 1498 insertions(+), 5 deletions(-) create mode 100644 frontend/.tanstack/tmp/b28ffd90-d4a86d43cefa197b56c330cba1429e4d create mode 100644 frontend/src/components/BankTypeSelect.tsx create mode 100644 frontend/src/components/FileHistoryTable.tsx create mode 100644 frontend/src/components/FileUploadDropzone.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/PricingCard.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/ui/empty.tsx create mode 100644 frontend/src/components/ui/spinner.tsx create mode 100644 frontend/src/lib/mock-data.ts create mode 100644 frontend/src/routes/_public.tsx create mode 100644 frontend/src/routes/_public/index.tsx create mode 100644 frontend/src/routes/_public/pricing.tsx create mode 100644 frontend/src/routes/_public/trial.tsx diff --git a/frontend/.tanstack/tmp/b28ffd90-d4a86d43cefa197b56c330cba1429e4d b/frontend/.tanstack/tmp/b28ffd90-d4a86d43cefa197b56c330cba1429e4d new file mode 100644 index 0000000000..0cfb2e6f68 --- /dev/null +++ b/frontend/.tanstack/tmp/b28ffd90-d4a86d43cefa197b56c330cba1429e4d @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_public/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/_public/"!
+} diff --git a/frontend/src/components/BankTypeSelect.tsx b/frontend/src/components/BankTypeSelect.tsx new file mode 100644 index 0000000000..9fb633683a --- /dev/null +++ b/frontend/src/components/BankTypeSelect.tsx @@ -0,0 +1,51 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +const VIETNAMESE_BANKS = [ + { value: "vietcombank", label: "Vietcombank (VCB)" }, + { value: "techcombank", label: "Techcombank (TCB)" }, + { value: "acb", label: "ACB (AC Bank)" }, + { value: "bidv", label: "BIDV" }, + { value: "vietinbank", label: "VietinBank" }, + { value: "sacombank", label: "SacomBank" }, + { value: "hdbank", label: "HDBank" }, + { value: "vpbank", label: "VPBank" }, + { value: "maritime", label: "Maritime Bank" }, + { value: "agribank", label: "Agribank" }, + { value: "other", label: "Other Bank" }, +] + +interface BankTypeSelectorProps { + value?: string + onValueChange?: (value: string) => void +} + +export function BankTypeSelect({ + value, + onValueChange, +}: BankTypeSelectorProps) { + return ( +
+ + +
+ ) +} diff --git a/frontend/src/components/FileHistoryTable.tsx b/frontend/src/components/FileHistoryTable.tsx new file mode 100644 index 0000000000..ed3940acf5 --- /dev/null +++ b/frontend/src/components/FileHistoryTable.tsx @@ -0,0 +1,92 @@ +import { FileText, Download, Eye } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { StatusBadge } from "./StatusBadge" +import { Empty } from "@/components/ui/empty" +import type { FileHistoryItem } from "@/lib/mock-data" + +interface FileHistoryTableProps { + files: FileHistoryItem[] +} + +export function FileHistoryTable({ files }: FileHistoryTableProps) { + if (files.length === 0) { + return ( + + +

No files uploaded yet

+

+ Start by uploading a bank statement to get started +

+
+ ) + } + + return ( +
+ + + + Filename + Bank + Size + Status + Date + Actions + + + + {files.map((file) => ( + + +
+ + {file.filename} +
+
+ {file.bankType} + {file.size} + + + + + {file.uploadDate} + + +
+ {file.status === "completed" && ( + <> + + + + )} +
+
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/FileUploadDropzone.tsx b/frontend/src/components/FileUploadDropzone.tsx new file mode 100644 index 0000000000..f351ac2ccf --- /dev/null +++ b/frontend/src/components/FileUploadDropzone.tsx @@ -0,0 +1,110 @@ +import { useState } from "react" +import { Upload, File, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" + +interface FileUploadDropzoneProps { + onFileSelect?: (file: File) => void + accept?: string +} + +export function FileUploadDropzone({ + onFileSelect, + accept = ".pdf,.csv,.txt", +}: FileUploadDropzoneProps) { + const [isDragActive, setIsDragActive] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setIsDragActive(true) + } else if (e.type === "dragleave") { + setIsDragActive(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragActive(false) + if (e.dataTransfer.files?.[0]) { + handleFile(e.dataTransfer.files[0]) + } + } + + const handleChange = (e: React.ChangeEvent) => { + if (e.target.files?.[0]) { + handleFile(e.target.files[0]) + } + } + + const handleFile = (file: File) => { + setSelectedFile(file) + onFileSelect?.(file) + } + + const handleClear = () => { + setSelectedFile(null) + } + + return ( + + {!selectedFile ? ( +
+
+ +
+

Upload bank statement

+

+ Drag and drop your file here, or click to browse +

+ +

+ Supported formats: PDF, CSV, TXT (Max 10MB) +

+
+ ) : ( +
+
+ +
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024).toFixed(2)} KB +

+
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000000..5a310e47bf --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,50 @@ +import { Link, useRouterState } from "@tanstack/react-router" +import { FileText } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Appearance } from "./Common/Appearance" + +const navItems = [ + { href: "/", label: "Home" }, + { href: "/dashboard", label: "Dashboard" }, + { href: "/pricing", label: "Pricing" }, +] + +export function Navbar() { + const { location } = useRouterState() + const pathname = location.pathname + + return ( + + ) +} diff --git a/frontend/src/components/PricingCard.tsx b/frontend/src/components/PricingCard.tsx new file mode 100644 index 0000000000..1976d25bb3 --- /dev/null +++ b/frontend/src/components/PricingCard.tsx @@ -0,0 +1,49 @@ +import { Link } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Check } from "lucide-react" +import type { PricingTier } from "@/lib/mock-data" + +interface PricingCardProps { + tier: PricingTier +} + +export function PricingCard({ tier }: PricingCardProps) { + return ( + +
+

{tier.name}

+

{tier.description}

+ +
+ {tier.price} + /month +
+ +
    + {tier.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + + +
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000000..bcb1a4b2d1 --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,47 @@ +import { CheckCircle2, Clock, AlertCircle, HardDrive } from "lucide-react" +import { Badge } from "@/components/ui/badge" + +interface StatusBadgeProps { + status: "pending" | "processing" | "completed" | "error" + className?: string +} + +const statusConfig = { + pending: { + icon: Clock, + label: "Pending", + variant: "outline" as const, + }, + processing: { + icon: HardDrive, + label: "Processing", + variant: "default" as const, + }, + completed: { + icon: CheckCircle2, + label: "Completed", + variant: "secondary" as const, + }, + error: { + icon: AlertCircle, + label: "Error", + variant: "destructive" as const, + }, +} + +export function StatusBadge({ status, className }: StatusBadgeProps) { + const config = statusConfig[status] + const Icon = config.icon + + return ( + + + {config.label} + + ) +} diff --git a/frontend/src/components/ui/empty.tsx b/frontend/src/components/ui/empty.tsx new file mode 100644 index 0000000000..9c51eb6404 --- /dev/null +++ b/frontend/src/components/ui/empty.tsx @@ -0,0 +1,103 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className, + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/frontend/src/components/ui/spinner.tsx b/frontend/src/components/ui/spinner.tsx new file mode 100644 index 0000000000..1251b6afa6 --- /dev/null +++ b/frontend/src/components/ui/spinner.tsx @@ -0,0 +1,15 @@ +import { Loader2Icon } from "lucide-react" +import { cn } from "@/lib/utils" + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ) +} + +export { Spinner } diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts new file mode 100644 index 0000000000..32f120d227 --- /dev/null +++ b/frontend/src/lib/mock-data.ts @@ -0,0 +1,96 @@ +export interface FileHistoryItem { + id: string + filename: string + uploadDate: string + size: string + status: "pending" | "processing" | "completed" | "error" + bankType: string +} + +export interface PricingTier { + name: string + price: string + description: string + features: string[] + cta: string + highlighted?: boolean +} + +export const mockFileHistory: FileHistoryItem[] = [ + { + id: "1", + filename: "Vietcombank_Statement_May_2024.pdf", + uploadDate: "2024-05-15", + size: "2.4 MB", + status: "completed" as const, + bankType: "Vietcombank", + }, + { + id: "2", + filename: "Techcombank_April_Statement.pdf", + uploadDate: "2024-04-28", + size: "1.8 MB", + status: "completed" as const, + bankType: "Techcombank", + }, + { + id: "3", + filename: "ACB_Statement_June.pdf", + uploadDate: "2024-06-10", + size: "3.1 MB", + status: "processing" as const, + bankType: "ACB", + }, + { + id: "4", + filename: "BIDV_May_Statement.pdf", + uploadDate: "2024-05-20", + size: "2.7 MB", + status: "completed" as const, + bankType: "BIDV", + }, +] + +export const pricingTiers: PricingTier[] = [ + { + name: "Starter", + price: "Free", + description: "Perfect for individuals getting started", + features: [ + "5 files per month", + "Basic format conversion", + "Email support", + "Up to 2 MB file size", + ], + cta: "Get Started", + }, + { + name: "Professional", + price: "99,000", + description: "For active accountants and small firms", + features: [ + "Unlimited files", + "Advanced formatting", + "Transaction categorization", + "Priority support", + "API access", + "Custom templates", + ], + cta: "Start Free Trial", + highlighted: true, + }, + { + name: "Enterprise", + price: "Custom", + description: "For large accounting firms", + features: [ + "Everything in Professional", + "Dedicated account manager", + "Custom integration", + "Team management", + "Advanced analytics", + "99.9% SLA", + ], + cta: "Contact Sales", + }, +] diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 08d665fef8..5b2ec0184c 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -18,6 +18,10 @@ import { Route as LayoutIndexRouteImport } from './routes/_layout/index' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' +import { Route as PublicRouteImport } from './routes/_public' +import { Route as PublicIndexRouteImport } from './routes/_public/index' +import { Route as PublicDashboardRouteImport } from './routes/_public/trial' +import { Route as PublicPricingRouteImport } from './routes/_public/pricing' const SignupRoute = SignupRouteImport.update({ id: '/signup', @@ -44,7 +48,7 @@ const LayoutRoute = LayoutRouteImport.update({ getParentRoute: () => rootRouteImport, } as any) const LayoutIndexRoute = LayoutIndexRouteImport.update({ - id: '/', + id: '/_layout/', path: '/', getParentRoute: () => LayoutRoute, } as any) @@ -63,9 +67,28 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ path: '/admin', getParentRoute: () => LayoutRoute, } as any) +const PublicRoute = PublicRouteImport.update({ + id: '/_public', + getParentRoute: () => rootRouteImport, +} as any) +const PublicIndexRoute = PublicIndexRouteImport.update({ + id: '/_public/', + path: '/', + getParentRoute: () => PublicRoute, +} as any) +const PublicDashboardRoute = PublicDashboardRouteImport.update({ + id: '/_public/dashboard', + path: '/dashboard', + getParentRoute: () => PublicRoute, +} as any) +const PublicPricingRoute = PublicPricingRouteImport.update({ + id: '/_public/pricing', + path: '/pricing', + getParentRoute: () => PublicRoute, +} as any) export interface FileRoutesByFullPath { - '/': typeof LayoutIndexRoute + '/': typeof PublicIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute @@ -73,6 +96,8 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute + '/dashboard': typeof PublicDashboardRoute + '/pricing': typeof PublicPricingRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -82,11 +107,14 @@ export interface FileRoutesByTo { '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute + '/': typeof PublicIndexRoute + '/dashboard': typeof PublicDashboardRoute + '/pricing': typeof PublicPricingRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren + '/_public': typeof PublicRouteWithChildren '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute @@ -95,6 +123,9 @@ export interface FileRoutesById { '/_layout/items': typeof LayoutItemsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_layout/': typeof LayoutIndexRoute + '/_public/': typeof PublicIndexRoute + '/_public/dashboard': typeof PublicDashboardRoute + '/_public/pricing': typeof PublicPricingRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -107,6 +138,8 @@ export interface FileRouteTypes { | '/admin' | '/items' | '/settings' + | '/dashboard' + | '/pricing' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -117,9 +150,12 @@ export interface FileRouteTypes { | '/items' | '/settings' | '/' + | '/dashboard' + | '/pricing' id: | '__root__' | '/_layout' + | '/_public' | '/login' | '/recover-password' | '/reset-password' @@ -128,10 +164,14 @@ export interface FileRouteTypes { | '/_layout/items' | '/_layout/settings' | '/_layout/' + | '/_public/' + | '/_public/dashboard' + | '/_public/pricing' fileRoutesById: FileRoutesById } export interface RootRouteChildren { LayoutRoute: typeof LayoutRouteWithChildren + PublicRoute: typeof PublicRouteWithChildren LoginRoute: typeof LoginRoute RecoverPasswordRoute: typeof RecoverPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute @@ -171,7 +211,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -203,6 +243,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } + '/_public': { + id: '/_public' + path: '' + fullPath: '' + preLoaderRoute: typeof PublicRouteImport + parentRoute: typeof rootRouteImport + } + '/_public/': { + id: '/_public/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof PublicIndexRouteImport + parentRoute: typeof PublicRoute + } + '/_public/dashboard': { + id: '/_public/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof PublicDashboardRouteImport + parentRoute: typeof PublicRoute + } + '/_public/pricing': { + id: '/_public/pricing' + path: '/pricing' + fullPath: '/pricing' + preLoaderRoute: typeof PublicPricingRouteImport + parentRoute: typeof PublicRoute + } } } @@ -223,8 +291,24 @@ const LayoutRouteChildren: LayoutRouteChildren = { const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) +interface PublicRouteChildren { + PublicIndexRoute: typeof PublicIndexRoute + PublicDashboardRoute: typeof PublicDashboardRoute + PublicPricingRoute: typeof PublicPricingRoute +} + +const PublicRouteChildren: PublicRouteChildren = { + PublicIndexRoute: PublicIndexRoute, + PublicDashboardRoute: PublicDashboardRoute, + PublicPricingRoute: PublicPricingRoute, +} + +const PublicRouteWithChildren = + PublicRoute._addFileChildren(PublicRouteChildren) + const rootRouteChildren: RootRouteChildren = { LayoutRoute: LayoutRouteWithChildren, + PublicRoute: PublicRouteWithChildren, LoginRoute: LoginRoute, RecoverPasswordRoute: RecoverPasswordRoute, ResetPasswordRoute: ResetPasswordRoute, diff --git a/frontend/src/routes/_public.tsx b/frontend/src/routes/_public.tsx new file mode 100644 index 0000000000..0d5fa883d3 --- /dev/null +++ b/frontend/src/routes/_public.tsx @@ -0,0 +1,15 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router" +import { Navbar } from "@/components/Navbar" + +export const Route = createFileRoute("/_public")({ + component: PublicLayout, +}) + +function PublicLayout() { + return ( +
+ + +
+ ) +} diff --git a/frontend/src/routes/_public/index.tsx b/frontend/src/routes/_public/index.tsx new file mode 100644 index 0000000000..a23c6fdf0b --- /dev/null +++ b/frontend/src/routes/_public/index.tsx @@ -0,0 +1,319 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { ArrowRight, Zap, Lock, Clock, FileText, Check } from "lucide-react" + +export const Route = createFileRoute("/_public/")({ + component: Home, + head: () => ({ + meta: [{ title: "BankToExcel - Convert Bank Statements to Excel" }], + }), +}) + +function Home() { + const features = [ + { + icon: Zap, + title: "Lightning Fast", + description: "Convert your bank statements to Excel in seconds, not minutes", + }, + { + icon: Lock, + title: "Secure & Private", + description: "Your data is encrypted and deleted immediately after conversion", + }, + { + icon: Clock, + title: "Batch Processing", + description: "Upload multiple files and process them simultaneously", + }, + { + icon: FileText, + title: "Auto-Detection", + description: "Automatically detects and formats data from any bank statement", + }, + ] + + const benefits = [ + "Supports all major Vietnamese banks", + "Customizable Excel templates", + "Transaction categorization", + "Monthly reconciliation reports", + "API integration available", + "Dedicated support", + ] + + return ( + <> + {/* Hero Section */} +
+
+
+

+ 🚀 New feature: Batch processing for unlimited files +

+
+ +

+ Convert Bank Statements to Excel in Seconds +

+ +

+ Stop wasting time manually copying bank transactions. BankToExcel + converts your statements to perfectly formatted Excel files instantly. + Built for accountants in Vietnam. +

+ +
+ + + + +
+ +
+

+ Trusted by accountants and finance teams +

+
+ {["Vietcombank", "Techcombank", "BIDV", "VietinBank", "ACB"].map( + (bank) => ( +
+ {bank} +
+ ), + )} +
+
+
+
+ + {/* Features Section */} +
+
+
+

+ Why choose BankToExcel? +

+

+ Designed specifically for Vietnamese accountants and finance + professionals +

+
+ +
+ {features.map((feature) => { + const Icon = feature.icon + return ( + + +

{feature.title}

+

{feature.description}

+
+ ) + })} +
+
+
+ + {/* Benefits Section */} +
+
+
+

+ Everything you need for efficient accounting +

+

+ From individual freelancers to large accounting firms, BankToExcel + provides all the tools you need to streamline your workflow. +

+
    + {benefits.map((benefit) => ( +
  • + + {benefit} +
  • + ))} +
+
+ +
+
+
+

+ Original PDF Statement +

+
+

+ Vietcombank Statement - May 2024 +

+

Account: ***4567

+

+ Opening Balance: 50,000,000 VND +

+
+
+
+ +
+
+

+ Converted Excel File +

+
+

+ 📊 transactions.xlsx +

+

✓ Formatted columns

+

+ ✓ Categorized entries +

+
+
+
+
+
+
+ + {/* Stats Section */} +
+
+
+
+

+ 500+ +

+

Files Converted Daily

+
+
+

+ 98% +

+

Customer Satisfaction

+
+
+

+ <5s +

+

Average Processing Time

+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to simplify your accounting? +

+

+ Join hundreds of accountants and finance teams saving hours every week +

+
+ + + + + + +
+
+
+ + {/* Footer */} +
+
+
+
+

Product

+
    +
  • + + Features + +
  • +
  • + + Pricing + +
  • +
  • + + Security + +
  • +
+
+
+

Company

+
    +
  • + + About + +
  • +
  • + + Blog + +
  • +
  • + + Contact + +
  • +
+
+
+

Legal

+
    +
  • + + Privacy + +
  • +
  • + + Terms + +
  • +
+
+
+

Support

+
    +
  • + + Help Center + +
  • +
  • + + Status + +
  • +
+
+
+ +
+

+ © 2024 BankToExcel. All rights reserved. +

+

+ Made for accountants in Vietnam 🇻🇳 +

+
+
+
+ + ) +} diff --git a/frontend/src/routes/_public/pricing.tsx b/frontend/src/routes/_public/pricing.tsx new file mode 100644 index 0000000000..12d0d01a45 --- /dev/null +++ b/frontend/src/routes/_public/pricing.tsx @@ -0,0 +1,304 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { PricingCard } from "@/components/PricingCard" +import { pricingTiers } from "@/lib/mock-data" +import { Check } from "lucide-react" + +export const Route = createFileRoute("/_public/pricing")({ + component: Pricing, + head: () => ({ + meta: [{ title: "Pricing - BankToExcel" }], + }), +}) + +function Pricing() { + const features = [ + { + category: "File Processing", + items: [ + { + name: "Files per month", + starter: "5", + professional: "Unlimited", + enterprise: "Unlimited", + }, + { + name: "File size limit", + starter: "2 MB", + professional: "50 MB", + enterprise: "Unlimited", + }, + { + name: "Processing speed", + starter: "Standard", + professional: "Fast", + enterprise: "Priority", + }, + ], + }, + { + category: "Features", + items: [ + { + name: "Basic conversion", + starter: true, + professional: true, + enterprise: true, + }, + { + name: "Advanced formatting", + starter: false, + professional: true, + enterprise: true, + }, + { + name: "Transaction categorization", + starter: false, + professional: true, + enterprise: true, + }, + { + name: "Custom templates", + starter: false, + professional: true, + enterprise: true, + }, + { + name: "API access", + starter: false, + professional: true, + enterprise: true, + }, + { + name: "Batch processing", + starter: false, + professional: true, + enterprise: true, + }, + ], + }, + { + category: "Support", + items: [ + { + name: "Email support", + starter: true, + professional: true, + enterprise: true, + }, + { + name: "Priority support", + starter: false, + professional: true, + enterprise: true, + }, + { + name: "Dedicated account manager", + starter: false, + professional: false, + enterprise: true, + }, + { + name: "Phone support", + starter: false, + professional: false, + enterprise: true, + }, + ], + }, + ] + + const faqs = [ + { + question: "Can I change my plan anytime?", + answer: + "Yes, you can upgrade or downgrade your plan at any time. Changes take effect immediately.", + }, + { + question: "Do you offer a refund policy?", + answer: + "We offer a 30-day money-back guarantee if you're not satisfied with our service.", + }, + { + question: "What banks do you support?", + answer: + "We support all major Vietnamese banks including Vietcombank, Techcombank, BIDV, VietinBank, ACB, and more.", + }, + { + question: "Is my data secure?", + answer: + "Yes, all data is encrypted in transit and at rest. We delete files immediately after conversion.", + }, + { + question: "Do you offer enterprise plans?", + answer: + "Yes, we have custom enterprise plans for large organizations. Contact our sales team for details.", + }, + { + question: "Can I use the API?", + answer: + "API access is included with Professional and Enterprise plans. See our documentation for more details.", + }, + ] + + return ( + <> + {/* Pricing Header */} +
+
+

+ Simple, Transparent Pricing +

+

+ Choose the plan that's right for you. No hidden fees, cancel anytime. +

+
+ + {/* Pricing Cards */} +
+ {pricingTiers.map((tier) => ( + + ))} +
+ + {/* Monthly / Annual Toggle */} +
+ Monthly + + Annual + + Save 20% + +
+
+ + {/* Comparison Table */} +
+
+

+ Detailed Comparison +

+
+ {features.map((section) => ( +
+

+ {section.category} +

+
+ + + + + + + + + + + {section.items.map((item) => ( + + + + + + + ))} + +
FeatureStarterProfessionalEnterprise
+ {item.name} + + {typeof item.starter === "boolean" ? ( + item.starter ? ( + + ) : ( + + ) + ) : ( + item.starter + )} + + {typeof item.professional === "boolean" ? ( + item.professional ? ( + + ) : ( + + ) + ) : ( + item.professional + )} + + {typeof item.enterprise === "boolean" ? ( + item.enterprise ? ( + + ) : ( + + ) + ) : ( + item.enterprise + )} +
+
+
+ ))} +
+
+
+ + {/* FAQ Section */} +
+
+

+ Frequently Asked Questions +

+

+ Can't find the answer you're looking for? Contact our support team. +

+
+
+ {faqs.map((faq) => ( + +
+ + {faq.question} + + ▼ + + +

+ {faq.answer} +

+
+
+ ))} +
+
+ + {/* CTA Section */} +
+
+

+ Ready to get started? +

+

+ Start with our free plan, no credit card required +

+ + + +
+
+ + ) +} diff --git a/frontend/src/routes/_public/trial.tsx b/frontend/src/routes/_public/trial.tsx new file mode 100644 index 0000000000..a6da576168 --- /dev/null +++ b/frontend/src/routes/_public/trial.tsx @@ -0,0 +1,149 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { BankTypeSelect } from "@/components/BankTypeSelect" +import { FileUploadDropzone } from "@/components/FileUploadDropzone" +import { FileHistoryTable } from "@/components/FileHistoryTable" +import { mockFileHistory } from "@/lib/mock-data" +import { ArrowRight } from "lucide-react" + +export const Route = createFileRoute("/_public/trial")({ + component: Trial, + head: () => ({ + meta: [{ title: "Trial - KeToanAuto" }], + }), +}) + +function Trial() { + const [selectedBank, setSelectedBank] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + const [isProcessing, setIsProcessing] = useState(false) + + const handleFileSelect = (file: File) => { + setSelectedFile(file) + } + + const handleConvert = async () => { + if (!selectedFile || !selectedBank) return + setIsProcessing(true) + await new Promise((resolve) => setTimeout(resolve, 2000)) + setIsProcessing(false) + setSelectedFile(null) + setSelectedBank("") + } + + return ( +
+ {/* Header */} +
+

Dashboard

+

+ Upload and convert your bank statements to Excel +

+
+ +
+ {/* Upload Section */} +
+ +

Convert Your Statement

+
+ +
+ + +
+ +
+

💡 Pro Tip:

+

+ Upload multiple statements to process them together. Our system + will automatically organize all transactions chronologically. +

+
+
+
+ + {/* Recent Conversions */} +
+

Recent Conversions

+ + + +
+
+ + {/* Sidebar Stats */} +
+ +
+

12

+

+ Files Processed This Month +

+
+
+
+

+ 60% of monthly quota +

+
+ + + +

Quick Stats

+
+
+ Total Files + 48 +
+
+ Total Transactions + 2,847 +
+
+ Storage Used + 245 MB +
+
+
+ + +

Upgrade to Pro

+

+ Get unlimited conversions and advanced features +

+ + + +
+
+
+
+ ) +} diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index a1f83d7e5a..0b1f412227 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -37,7 +37,7 @@ export const Route = createFileRoute("/login")({ beforeLoad: async () => { if (isLoggedIn()) { throw redirect({ - to: "/", + to: "/dashboard", }) } }, From 1ec1dd0c098bd27db4da7fe5aaff5f1b1b4fc5bf Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 29 Mar 2026 15:55:34 +0700 Subject: [PATCH 05/30] init --- .DS_Store | Bin 0 -> 6148 bytes .env | 9 +- .../versions/315fb0cb1b81_edit_model_1.py | 33 ++ .../versions/3978fdec9e0b_edit_model_3.py | 29 ++ .../versions/998082d0b0f6_edit_model_2.py | 29 ++ .../versions/e748195f27c0_edit_model_4.py | 33 ++ backend/app/api/deps.py | 10 - backend/app/aws/client.py | 16 +- backend/app/core/config.py | 6 + backend/app/files/models.py | 9 +- backend/app/files/router.py | 134 ++++++- backend/app/files/schemas.py | 10 +- backend/app/files/service.py | 60 +++- backend/app/files/utils.py | 42 ++- backend/app/helpers/s3.py | 2 +- backend/app/items/router.py | 2 +- backend/app/items/schemas.py | 2 +- backend/app/items/service.py | 4 +- backend/app/main.py | 1 - backend/app/ocrs/constants.py | 5 + backend/app/ocrs/dependencies.py | 4 + backend/app/ocrs/schemas.py | 76 ++++ backend/app/ocrs/service.py | 91 +++++ backend/app/utils.py | 13 +- backend/pyproject.toml | 2 + frontend/.env | 1 + frontend/package.json | 1 + frontend/src/client/schemas.gen.ts | 249 ++++++++++--- frontend/src/client/sdk.gen.ts | 148 +++++++- frontend/src/client/types.gen.ts | 99 ++++- frontend/src/components/FileHistoryTable.tsx | 50 ++- .../src/components/FileUploadDropzone.tsx | 318 ++++++++++++---- frontend/src/components/Files/columns.tsx | 159 ++++++++ frontend/src/components/Items/columns.tsx | 73 ---- frontend/src/components/PricingCard.tsx | 2 +- .../src/components/Sidebar/AppSidebar.tsx | 6 +- frontend/src/components/StatusBadge.tsx | 24 +- .../components/loading-spinner-provider.tsx | 61 ++++ frontend/src/hooks/useAuth.ts | 4 +- frontend/src/lib/mock-data.ts | 10 +- frontend/src/main.tsx | 11 +- frontend/src/routeTree.gen.ts | 165 +++++---- frontend/src/routes/_layout/dashboard.tsx | 170 +++++++++ frontend/src/routes/_layout/files.tsx | 104 ++++++ frontend/src/routes/_layout/index.tsx | 338 ++++++++++++++++-- frontend/src/routes/_layout/items.tsx | 12 +- .../routes/_public/{index.tsx => home.tsx} | 28 +- frontend/src/routes/_public/pricing.tsx | 7 +- frontend/src/routes/_public/trial.tsx | 149 -------- frontend/src/utils.ts | 2 + package-lock.json | 13 + uv.lock | 251 ++++++++++++- 52 files changed, 2523 insertions(+), 554 deletions(-) create mode 100644 .DS_Store create mode 100644 backend/app/alembic/versions/315fb0cb1b81_edit_model_1.py create mode 100644 backend/app/alembic/versions/3978fdec9e0b_edit_model_3.py create mode 100644 backend/app/alembic/versions/998082d0b0f6_edit_model_2.py create mode 100644 backend/app/alembic/versions/e748195f27c0_edit_model_4.py delete mode 100644 backend/app/api/deps.py create mode 100644 backend/app/ocrs/constants.py create mode 100644 backend/app/ocrs/dependencies.py create mode 100644 backend/app/ocrs/schemas.py create mode 100644 backend/app/ocrs/service.py create mode 100644 frontend/src/components/Files/columns.tsx delete mode 100644 frontend/src/components/Items/columns.tsx create mode 100644 frontend/src/components/loading-spinner-provider.tsx create mode 100644 frontend/src/routes/_layout/dashboard.tsx create mode 100644 frontend/src/routes/_layout/files.tsx rename frontend/src/routes/_public/{index.tsx => home.tsx} (93%) delete mode 100644 frontend/src/routes/_public/trial.tsx diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1495e773ad815c30058437203fe56af5aa7c4f45 GIT binary patch literal 6148 zcmeHK%Sr=55UkdKfn0L*IKSW@3?Y6&ejvt(2W168&wKK_@@c7l5DXhHf)}ZV?waZ8 znq}*-y$!%tANxCC31Cil#FvMu`MLYVt}0_hI`4SGfc@v@eRzGFR9{Xw_XayW;1Mt1 z`NMPGdYPuI6p#W^Knh3!Dey}Ly!X=Pi$p~!AO)nrNdf;pG`eFi924Wy!4M+=amI8Q z*D*^Fn`tS2f;0V!~< zz;$ky-v96EKg|E str: +def generate_presigned_put_url(key: str, bucket: str | None = None, expiration: int = 60*60*24*7) -> str: bucket = bucket or aws_settings.R2_BUCKET_NAME if not bucket: raise RuntimeError("S3 bucket not configured") client = get_s3_client() params = {"Bucket": bucket, "Key": key} + logger.info(f"Generating presigned PUT URL for bucket {bucket} and key {key} with expiration {expiration} seconds") url = client.generate_presigned_url( - ClientMethod="put_object", Params=params, ExpiresIn=expiration + ClientMethod="get_object", Params=params, ExpiresIn=expiration ) + logger.info(f"Generated presigned URL for key {key}: {url}") return url -def upload_file(key: str, data: bytes, bucket: str | None = None, content_type: str | None = None) -> dict: +def upload_file_to_r2(key: str, data: bytes, bucket: str | None = None, content_type: str | None = None, presign: bool = False) -> dict: bucket = bucket or aws_settings.R2_BUCKET_NAME if not bucket: raise RuntimeError("S3 bucket not configured") @@ -43,5 +48,10 @@ def upload_file(key: str, data: bytes, bucket: str | None = None, content_type: if content_type: extra_args["ContentType"] = content_type + logger.info(f"Uploading file to R2 with key {key} and content type {content_type}") resp = client.put_object(Bucket=bucket, Key=key, Body=data, **extra_args) + logger.info(f"Uploaded file to R2 with key {key}") + if presign: + resp["PresignedURL"] = generate_presigned_put_url(key=key, bucket=bucket) + return resp diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5cdb475f11..3bcce383e0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -122,6 +122,12 @@ def _enforce_non_default_secrets(self) -> Self: ) return self + + OCR_API_URL: HttpUrl | None = None + OCR_API_TOKEN: str | None = None + OCR_JOB_URL: HttpUrl | None = None + OCR_JOB_POLLING_INTERVAL: int = 5 # in seconds + OCR_MODEL: str = "PaddleOCR-VL" settings = Settings() # type: ignore diff --git a/backend/app/files/models.py b/backend/app/files/models.py index 572aa5cc9f..8b75a5ace7 100644 --- a/backend/app/files/models.py +++ b/backend/app/files/models.py @@ -1,9 +1,11 @@ from __future__ import annotations +from alembic.util import err from app.utils import get_datetime_utc import uuid from datetime import datetime from sqlalchemy import DateTime from sqlmodel import Field, SQLModel +from app.ocrs.constants import OcrJobStatus class File(SQLModel, table=True): __tablename__ = "files" @@ -12,11 +14,14 @@ class File(SQLModel, table=True): content_type: str = Field(min_length=1, max_length=255) size: int | None = None url: str | None = None - created_at: datetime | None = Field( + job_id: str | None = Field(default=None, max_length=255, index=True) + job_status: str | None = Field(default=OcrJobStatus.PENDING, max_length=50) + err_msg: str | None = Field(default=None, max_length=255) + bank: str | None = Field(default=None, max_length=255) + created_at: datetime = Field( default_factory=get_datetime_utc, sa_type=DateTime(timezone=True), # type: ignore[call-arg] ) user_id: uuid.UUID = Field( foreign_key="users.id", nullable=False, ondelete="CASCADE" ) - # owner: User | None = Relationship(back_populates="files") diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 8c9c2b288a..5a323aa191 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -1,34 +1,55 @@ -from fastapi import APIRouter, File, HTTPException, UploadFile +from app.backend_pre_start import logger +import uuid +from urllib.parse import quote -from app.aws.client import upload_file +from fastapi import APIRouter, HTTPException, Response, UploadFile +from sqlmodel import select + +from app.aws.client import upload_file_to_r2 from app.aws.config import aws_settings from app.aws.schemas import PresignRequest, PresignResponse +from app.files.dependencies import CurrentUser, SessionDep +from app.files.models import File +from app.files.schemas import FileCreate, FilePublic, FilesPublic +from app.files.service import create_file, delete_file, update_file_job_status, download_excel_file +from app.ocrs.service import get_update_file_ocr, upload_ocr_job, get_job_status -router = APIRouter(prefix="/files", tags=["files"]) +from sqlalchemy import desc +router = APIRouter(prefix="/files", tags=["files"]) -@router.post("/") +@router.post("/", response_model=FilePublic) def upload_file_endpoint( - file: UploadFile = File() # noqa: B008 + session: SessionDep, + user: CurrentUser, + file: UploadFile # noqa: B008, ): """ Upload a file to R2/S3 storage. """ file_bytes = file.file.read() - response = upload_file( - key=file.filename or "upload", - data=file_bytes, - content_type=file.content_type, - ) - return { - "filename": file.filename, - "content_type": file.content_type, - "size": len(file_bytes), - "s3_response": response, - } + file_name = file.filename or "upload" + file_type = file.content_type or "application/octet-stream" + file_create = FileCreate(filename=file_name, content_type=file_type, size=len(file_bytes), url="") + file_result = create_file(session=session, file_in=file_create, user_id=user.id) + try: + logger.info(f"Created DB record for file {file_result.id} with name {file_name}") + r2_result = upload_file_to_r2( + key=user.email + "/" + str(file_result.id) + "/" + file_name, # Use DB record ID for unique key + data=file_bytes, + content_type=file_type, + presign=True + ) + upload_ocr_job(session=session, file=file_result, file_url=r2_result["PresignedURL"]) -@router.post("/presign", response_model=PresignResponse) + return file_result + except Exception as exc: + delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + logger.error(f"Error uploading file {file_name}: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) + +@router.get("/presign", response_model=PresignResponse) def presign_upload(req: PresignRequest): """ Generate a presigned PUT URL for direct client uploads. @@ -45,3 +66,82 @@ def presign_upload(req: PresignRequest): raise HTTPException(status_code=500, detail=str(exc)) return PresignResponse(url=url, key=key) + + +@router.put("/{file_id}?job_status={job_status}") +def update_file_job_status_endpoint( + file_id: uuid.UUID, + job_status: str, + session: SessionDep, +): + """ + Update the job status for a file based on OCR job updates. + """ + + updated_file = update_file_job_status(session=session, file_id=file_id, job_status=job_status) + if not updated_file: + raise HTTPException(status_code=404, detail="File not found") + return {"message": "Job status updated", "file_id": str(updated_file.id), "job_status": updated_file.job_status} + +@router.get("/{file_id}/status", response_model=FilePublic) +def get_file_status(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Get the current status of a file, including OCR job status if applicable. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + + ocr_result = get_update_file_ocr(file=file, session=session, user=user) # Poll OCR API for latest status + file.job_status = ocr_result.data.state # Update file status based on OCR job state + return file + +@router.get('/', response_model=FilesPublic) +def list_files(session: SessionDep, user: CurrentUser, skip: int = 0, limit: int = 0): + """ + List all files uploaded by the current user. + """ + user_id = user.id + if limit <= 0: + statement = select(File).where(File.user_id == user_id).order_by(desc(File.created_at)) # ty:ignore[invalid-argument-type] + else: + statement = select(File).where(File.user_id == user_id).order_by(desc(File.created_at)).offset(skip).limit(limit) # ty:ignore[invalid-argument-type] + + files = session.exec(statement).all() + + return FilesPublic(data=files, count=len(files)) # ty:ignore[invalid-argument-type] + +@router.post("/{file_id}/download") +def download_table_excel_file(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Stream an Excel file built from the OCR result JSON stored in R2. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") + if file.job_status != "done": + raise HTTPException(status_code=400, detail="OCR job is not done yet") + + excel_bytes, content_disposition = download_excel_file(file=file, user=user) + + return Response( + content=excel_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": content_disposition}, + ) + +@router.get("jobs/{job_id}/status") +def get_job_status_endpoint(job_id: str, session: SessionDep): + """ + Get the status of an OCR job by job ID. + """ + # This endpoint can be used by a background worker to poll OCR job status if needed + # For now, we handle polling in the get_file_status endpoint, but this can be useful for more direct checks + + try: + ocr_status = get_job_status(job_id=job_id) + return {"job_id": job_id, "status": ocr_status} + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) \ No newline at end of file diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py index 21561cfcff..370f6e9dc5 100644 --- a/backend/app/files/schemas.py +++ b/backend/app/files/schemas.py @@ -3,23 +3,21 @@ from sqlmodel import Field, SQLModel - class FileBase(SQLModel): filename: str = Field(min_length=1, max_length=255) content_type: str = Field(min_length=1, max_length=255) size: int | None = None - + job_id: str | None = Field(default=None, max_length=255) + job_status: str | None = Field(default=None, max_length=50) class FileCreate(FileBase): url: str | None = None - class FilePublic(FileBase): id: uuid.UUID created_at: datetime | None = None - owner_id: uuid.UUID - + user_id: uuid.UUID class FilesPublic(SQLModel): data: list[FilePublic] - count: int + count: int \ No newline at end of file diff --git a/backend/app/files/service.py b/backend/app/files/service.py index 0dedecaa57..a7ce8c6b52 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -1,14 +1,70 @@ +import io +from app.files.utils import get_df_from_result_json +from app.aws.config import aws_settings +from app.aws.client import generate_presigned_put_url import uuid +from urllib.parse import quote +from app.files.dependencies import CurrentUser, SessionDep from sqlmodel import Session from app.files.models import File from app.files.schemas import FileCreate +import pandas as pd # type: ignore[import] -def create_file(*, session: Session, file_in: FileCreate, owner_id: uuid.UUID) -> File: - db_file = File.model_validate(file_in, update={"owner_id": owner_id}) + +def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: + db_file = File.model_validate(file_in, update={"user_id": user_id}) session.add(db_file) session.commit() session.refresh(db_file) return db_file + +def delete_file(*, session: Session, file_id: uuid.UUID) -> None: + db_file = session.get(File, file_id) + if db_file: + session.delete(db_file) + session.commit() + +def update_file_job_status( + session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None +) -> File | None: + db_file: File | None = session.get(File, file_id) + if db_file: + db_file.job_status = job_status + if job_id: + db_file.job_id = job_id + if err_msg: + db_file.err_msg = err_msg + session.add(db_file) + session.commit() + session.refresh(db_file) + return db_file + return None + +def download_excel_file(file: File, user: CurrentUser) -> tuple[bytes, str]: + """ + Given a File record, download the file content from its URL and return as bytes. + """ + json_key = f"{user.email}/{file.id}/result.json" + presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) + + df = get_df_from_result_json(presigned_url) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] + df.to_excel(writer, index=False, sheet_name="OCR Tables") + excel_bytes = output.getvalue() + + safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename + excel_filename = f"{safe_name}_tables.xlsx" + + # RFC 5987: percent-encode the UTF-8 filename for the filename* parameter + encoded_filename = quote(excel_filename, safe="") + content_disposition = ( + f"attachment; filename=\"tables.xlsx\"; " + f"filename*=UTF-8''{encoded_filename}" + ) + + return (excel_bytes, content_disposition) \ No newline at end of file diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py index 503d2fe00f..fb054e1586 100644 --- a/backend/app/files/utils.py +++ b/backend/app/files/utils.py @@ -1 +1,41 @@ -# File-related utility functions +from pandas import DataFrame +from io import StringIO +import requests +import pandas as pd + +def extract_tables_from_ocr(data) -> pd.DataFrame: + all_dfs: list[DataFrame] = [] + + pages = data["result"]["layoutParsingResults"] + + for page_idx, page in enumerate(pages): + blocks = page.get("prunedResult", {}).get("parsing_res_list", []) + + for block in blocks: + if block.get("block_label") == "table": + html = block.get("block_content") + + try: + dfs = pd.read_html(StringIO(html)) + for df in dfs: + df["__page__"] = page_idx + 1 # debug tracking + all_dfs.append(df) + except Exception as e: + print(f"Skip bad table on page {page_idx+1}: {e}") + + if not all_dfs: + raise Exception("No tables found") + + # Merge all tables + merged = pd.concat(all_dfs, ignore_index=True) + + return merged + + +def get_df_from_result_json(url) -> DataFrame: + res = requests.get(url) + res.raise_for_status() + + data = res.json() + + return extract_tables_from_ocr(data) \ No newline at end of file diff --git a/backend/app/helpers/s3.py b/backend/app/helpers/s3.py index 2302f88629..8469c92af5 100644 --- a/backend/app/helpers/s3.py +++ b/backend/app/helpers/s3.py @@ -1,2 +1,2 @@ # Backwards-compatibility shim – S3 client now lives in app.aws.client -from app.aws.client import generate_presigned_put_url, get_s3_client, upload_file as upload_r2_file # noqa: F401 +from app.aws.client import generate_presigned_put_url, get_s3_client, upload_file_to_r2 as upload_r2_file # noqa: F401 diff --git a/backend/app/items/router.py b/backend/app/items/router.py index 014cfa4e91..c5c9cbcb8f 100644 --- a/backend/app/items/router.py +++ b/backend/app/items/router.py @@ -67,7 +67,7 @@ def create_item_endpoint( """ Create new item. """ - item = create_item(session=session, item_in=item_in, owner_id=current_user.id) + item = create_item(session=session, item_in=item_in, user_id=current_user.id) return item diff --git a/backend/app/items/schemas.py b/backend/app/items/schemas.py index a0106b8479..72d1e3d1d3 100644 --- a/backend/app/items/schemas.py +++ b/backend/app/items/schemas.py @@ -23,7 +23,7 @@ class ItemUpdate(ItemBase): # Properties to return via API, id is always required class ItemPublic(ItemBase): id: uuid.UUID - owner_id: uuid.UUID + user_id: uuid.UUID created_at: datetime | None = None diff --git a/backend/app/items/service.py b/backend/app/items/service.py index 2afe27b42d..545822c3d7 100644 --- a/backend/app/items/service.py +++ b/backend/app/items/service.py @@ -6,8 +6,8 @@ from app.items.schemas import ItemCreate, ItemUpdate -def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: - db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) +def create_item(*, session: Session, item_in: ItemCreate, user_id: uuid.UUID) -> Item: + db_item = Item.model_validate(item_in, update={"user_id": user_id}) session.add(db_item) session.commit() session.refresh(db_item) diff --git a/backend/app/main.py b/backend/app/main.py index 10adb52ec6..5caacd8c32 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,6 @@ def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" - if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) diff --git a/backend/app/ocrs/constants.py b/backend/app/ocrs/constants.py new file mode 100644 index 0000000000..9cbb42e7f5 --- /dev/null +++ b/backend/app/ocrs/constants.py @@ -0,0 +1,5 @@ +class OcrJobStatus: + PENDING = "pending" + RUNNING = "running" + DONE = "done" + FAILED = "failed" diff --git a/backend/app/ocrs/dependencies.py b/backend/app/ocrs/dependencies.py new file mode 100644 index 0000000000..ecac90d4ce --- /dev/null +++ b/backend/app/ocrs/dependencies.py @@ -0,0 +1,4 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, +) diff --git a/backend/app/ocrs/schemas.py b/backend/app/ocrs/schemas.py new file mode 100644 index 0000000000..197a6a7734 --- /dev/null +++ b/backend/app/ocrs/schemas.py @@ -0,0 +1,76 @@ +from pydantic import BaseModel + + +# --------------------------------------------------------------------------- +# Nested models +# --------------------------------------------------------------------------- + +class ExtractProgress(BaseModel): + totalPages: int | None = None + extractedPages: int | None = None + startTime: str | None = None + endTime: str | None = None + + +class ResultUrl(BaseModel): + jsonUrl: str | None = None + markdownUrl: str | None = None + + +# --------------------------------------------------------------------------- +# Submit-job response (POST /jobs) +# --------------------------------------------------------------------------- + +class OcrSubmitData(BaseModel): + jobId: str + + +class OcrSubmitResponse(BaseModel): + traceId: str | None = None + code: int | None = None + msg: str | None = None + data: OcrSubmitData + + +# --------------------------------------------------------------------------- +# Job-level response (GET /jobs/{jobId}) +# --------------------------------------------------------------------------- + +class OcrJobData(BaseModel): + jobId: str + state: str # OcrJobStatus values: pending | running | done | failed + extractProgress: ExtractProgress | None = None + resultUrl: ResultUrl | None = None + errorMsg: str | None = None + + +class OcrJobResponse(BaseModel): + traceId: str | None = None + code: int | None = None + msg: str | None = None + data: OcrJobData + + +# --------------------------------------------------------------------------- +# Batch-job response (GET /jobs/batch/{batchId}) +# --------------------------------------------------------------------------- + +class OcrBatchExtractResult(BaseModel): + jobId: str + state: str # pending | running | done | failed + extractProgress: ExtractProgress | None = None + resultUrl: ResultUrl | None = None + errorMsg: str | None = None + + +class OcrBatchData(BaseModel): + batchId: str + extractResult: list[OcrBatchExtractResult] + + +class OcrBatchResponse(BaseModel): + traceId: str | None = None + code: int | None = None + msg: str | None = None + data: OcrBatchData + diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py new file mode 100644 index 0000000000..e7125d2964 --- /dev/null +++ b/backend/app/ocrs/service.py @@ -0,0 +1,91 @@ +from app.utils import get_bytes_from_file_url +from app.aws.client import upload_file_to_r2 +import logging +import requests +from sqlmodel import Session +from app.core.config import settings +from app.files.models import File +from app.files.service import update_file_job_status +from app.ocrs.constants import OcrJobStatus +from app.ocrs.dependencies import SessionDep, CurrentUser +from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse, OcrJobResponse + +logger = logging.getLogger(__name__) + +def upload_ocr_job(session: Session, file: File, file_url: str) -> File: + """ + Submit an OCR job for the given file URL and update job_id / job_status + on the File record. Only posts the job — polling is handled separately. + """ + logger.info(f"Starting OCR job submission for file {file.id} with URL {file_url}") + + headers = { + "Authorization": f"bearer {settings.OCR_API_TOKEN}", + "Content-Type": "application/json", + } + + optional_payload = { + "useDocOrientationClassify": False, + "useDocUnwarping": False, + "useChartRecognition": False, + } + + payload = { + "fileUrl": file_url, + "model": settings.OCR_MODEL, + "optionalPayload": optional_payload, + } + logger.info(f"Submitting OCR job for file {file.id} with payload: {payload}") + raw = requests.post(str(settings.OCR_JOB_URL), json=payload, headers=headers) + logger.info(f"OCR job submission response for file {file.id}: {raw.status_code} - {raw.text}") + raw.raise_for_status() + logger.info(f"Submitted OCR job for file {file.id}, response: {raw.json()}") + + submit_response = OcrSubmitResponse.model_validate(raw.json()) + + update_file_job_status(session, file.id, job_status=OcrJobStatus.RUNNING, job_id=submit_response.data.jobId) + logger.info(f"Updated file {file.id} job status to {OcrJobStatus.RUNNING} with job ID {submit_response.data.jobId}") + return file + +def get_update_file_ocr(file: File, session: SessionDep, user: CurrentUser) -> OcrJobResponse: + """ + Poll the OCR API for job results. Returns a typed OcrJobResponse. + """ + headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} + raw = requests.get(f"{settings.OCR_JOB_URL}/{file.job_id}", headers=headers) + assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" + result: OcrJobResponse = OcrJobResponse.model_validate(raw.json()) + state = result.data.state + if state != OcrJobStatus.PENDING: + print(f"OCR job {file.job_id} status: {state}") + update_file_job_status(session, file_id=file.id, job_status=state) # Update all files with this job_id + + if state == OcrJobStatus.DONE: + progress = result.data.extractProgress + logger.info( + "Job %s completed — pages: %s, start: %s, end: %s", + file.job_id, + progress.extractedPages if progress else None, + progress.startTime if progress else None, + progress.endTime if progress else None, + ) + key = f"{user.email}/{file.id}/result.json" + (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) + if json_url: + upload_file_to_r2(key=key, data=get_bytes_from_file_url(json_url), content_type="application/json") + if md_url: + upload_file_to_r2(key=key.replace(".json", ".md"), data=get_bytes_from_file_url(md_url), content_type="text/markdown") + elif state == OcrJobStatus.FAILED: + logger.error("OCR job %s failed: %s", file.job_id, result.data.errorMsg) + update_file_job_status(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) + + return result + +def get_job_status(job_id: str) -> object: + """ + Get the current status of an OCR job by job ID. Returns a typed OcrJobResponse. + """ + headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} + raw = requests.get(f"{settings.OCR_JOB_URL}/{job_id}", headers=headers) + assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" + return raw.json() \ No newline at end of file diff --git a/backend/app/utils.py b/backend/app/utils.py index 961de45686..408be7e713 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -20,4 +20,15 @@ def get_datetime_utc() -> datetime: - return datetime.now(timezone.utc) \ No newline at end of file + return datetime.now(timezone.utc) + +def get_bytes_from_file_url(file_url: str) -> bytes: + """ + Utility function to fetch file bytes from a given URL. + This can be used for processing files stored in R2/S3 or other locations. + """ + import requests + + response = requests.get(file_url) + response.raise_for_status() + return response.content \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index be62799eb2..67b5773427 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "pwdlib[argon2,bcrypt]>=0.3.0", "boto3>=1.26.0", "python-multipart>=0.0.22", + "pandas>=2.0.0,<3.0.0", + "openpyxl>=3.1.0,<4.0.0", ] [dependency-groups] diff --git a/frontend/.env b/frontend/.env index 27fcbfe8c8..ef660bcfd5 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1,3 @@ VITE_API_URL=http://localhost:8000 MAILCATCHER_HOST=http://localhost:1080 +MAXIMUM_FILE_SIZE=10485760 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index f3b8d23e24..84bd781bfa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.20", "form-data": "4.0.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb66c1f837..fd5ea95f9b 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1,5 +1,18 @@ // This file is auto-generated by @hey-api/openapi-ts +export const Body_files_upload_file_endpointSchema = { + properties: { + file: { + type: 'string', + format: 'binary', + title: 'File' + } + }, + type: 'object', + required: ['file'], + title: 'Body_files-upload_file_endpoint' +} as const; + export const Body_login_login_access_tokenSchema = { properties: { grant_type: { @@ -57,6 +70,102 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } as const; +export const FilePublicSchema = { + properties: { + filename: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Filename' + }, + content_type: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Content Type' + }, + size: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Size' + }, + job_id: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Job Id' + }, + job_status: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Job Status' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + } + }, + type: 'object', + required: ['filename', 'content_type', 'id', 'user_id'], + title: 'FilePublic' +} as const; + +export const FilesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/FilePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'FilesPublic' +} as const; + export const HTTPValidationErrorSchema = { properties: { detail: { @@ -122,10 +231,10 @@ export const ItemPublicSchema = { format: 'uuid', title: 'Id' }, - owner_id: { + user_id: { type: 'string', format: 'uuid', - title: 'Owner Id' + title: 'User Id' }, created_at: { anyOf: [ @@ -141,7 +250,7 @@ export const ItemPublicSchema = { } }, type: 'object', - required: ['title', 'id', 'owner_id'], + required: ['title', 'id', 'user_id'], title: 'ItemPublic' } as const; @@ -226,6 +335,56 @@ export const NewPasswordSchema = { title: 'NewPassword' } as const; +export const PresignRequestSchema = { + properties: { + filename: { + type: 'string', + title: 'Filename' + }, + content_type: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Content Type' + }, + bucket: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Bucket' + } + }, + type: 'object', + required: ['filename'], + title: 'PresignRequest' +} as const; + +export const PresignResponseSchema = { + properties: { + url: { + type: 'string', + title: 'Url' + }, + key: { + type: 'string', + title: 'Key' + } + }, + type: 'object', + required: ['url', 'key'], + title: 'PresignResponse' +} as const; + export const PrivateUserCreateSchema = { properties: { email: { @@ -296,15 +455,11 @@ export const UserCreateSchema = { format: 'email', title: 'Email' }, - is_active: { - type: 'boolean', - title: 'Is Active', - default: true - }, - is_superuser: { - type: 'boolean', - title: 'Is Superuser', - default: false + password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'Password' }, full_name: { anyOf: [ @@ -318,11 +473,15 @@ export const UserCreateSchema = { ], title: 'Full Name' }, - password: { - type: 'string', - maxLength: 128, - minLength: 8, - title: 'Password' + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + is_superuser: { + type: 'boolean', + title: 'Is Superuser', + default: false } }, type: 'object', @@ -332,9 +491,13 @@ export const UserCreateSchema = { export const UserPublicSchema = { properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, email: { type: 'string', - maxLength: 255, format: 'email', title: 'Email' }, @@ -351,8 +514,7 @@ export const UserPublicSchema = { full_name: { anyOf: [ { - type: 'string', - maxLength: 255 + type: 'string' }, { type: 'null' @@ -360,11 +522,6 @@ export const UserPublicSchema = { ], title: 'Full Name' }, - id: { - type: 'string', - format: 'uuid', - title: 'Id' - }, created_at: { anyOf: [ { @@ -379,7 +536,7 @@ export const UserPublicSchema = { } }, type: 'object', - required: ['email', 'id'], + required: ['id', 'email'], title: 'UserPublic' } as const; @@ -430,15 +587,18 @@ export const UserUpdateSchema = { ], title: 'Email' }, - is_active: { - type: 'boolean', - title: 'Is Active', - default: true - }, - is_superuser: { - type: 'boolean', - title: 'Is Superuser', - default: false + password: { + anyOf: [ + { + type: 'string', + maxLength: 128, + minLength: 8 + }, + { + type: 'null' + } + ], + title: 'Password' }, full_name: { anyOf: [ @@ -452,18 +612,27 @@ export const UserUpdateSchema = { ], title: 'Full Name' }, - password: { + is_active: { anyOf: [ { - type: 'string', - maxLength: 128, - minLength: 8 + type: 'boolean' }, { type: 'null' } ], - title: 'Password' + title: 'Is Active' + }, + is_superuser: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Superuser' } }, type: 'object', diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..e4bb0dd57c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,137 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesPresignUploadData, FilesPresignUploadResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; + +export class FilesService { + /** + * Upload File Endpoint + * Upload a file to R2/S3 storage. + * @param data The data for the request. + * @param data.formData + * @returns FilePublic Successful Response + * @throws ApiError + */ + public static uploadFileEndpoint(data: FilesUploadFileEndpointData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/files/', + formData: data.formData, + mediaType: 'multipart/form-data', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * List Files + * List all files uploaded by the current user. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns FilesPublic Successful Response + * @throws ApiError + */ + public static listFiles(data: FilesListFilesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/files/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Presign Upload + * Generate a presigned PUT URL for direct client uploads. + * @param data The data for the request. + * @param data.requestBody + * @returns PresignResponse Successful Response + * @throws ApiError + */ + public static presignUpload(data: FilesPresignUploadData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/files/presign', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update File Job Status Endpoint + * Update the job status for a file based on OCR job updates. + * @param data The data for the request. + * @param data.fileId + * @param data.jobStatus + * @returns unknown Successful Response + * @throws ApiError + */ + public static updateFileJobStatusEndpoint(data: FilesUpdateFileJobStatusEndpointData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/files/{file_id}?job_status={job_status}', + path: { + file_id: data.fileId, + job_status: data.jobStatus + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get File Status + * Get the current status of a file, including OCR job status if applicable. + * @param data The data for the request. + * @param data.fileId + * @returns FilePublic Successful Response + * @throws ApiError + */ + public static getFileStatus(data: FilesGetFileStatusData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/files/{file_id}/status', + path: { + file_id: data.fileId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Download Table Excel File + * Generate a presigned URL for downloading the OCR result JSON as an Excel file. + * @param data The data for the request. + * @param data.fileId + * @returns unknown Successful Response + * @throws ApiError + */ + public static downloadTableExcelFile(data: FilesDownloadTableExcelFileData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/files/{file_id}/download', + path: { + file_id: data.fileId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} export class ItemsService { /** @@ -30,14 +160,14 @@ export class ItemsService { } /** - * Create Item + * Create Item Endpoint * Create new item. * @param data The data for the request. * @param data.requestBody * @returns ItemPublic Successful Response * @throws ApiError */ - public static createItem(data: ItemsCreateItemData): CancelablePromise { + public static createItemEndpoint(data: ItemsCreateItemEndpointData): CancelablePromise { return __request(OpenAPI, { method: 'POST', url: '/api/v1/items/', @@ -71,7 +201,7 @@ export class ItemsService { } /** - * Update Item + * Update Item Endpoint * Update an item. * @param data The data for the request. * @param data.id @@ -79,7 +209,7 @@ export class ItemsService { * @returns ItemPublic Successful Response * @throws ApiError */ - public static updateItem(data: ItemsUpdateItemData): CancelablePromise { + public static updateItemEndpoint(data: ItemsUpdateItemEndpointData): CancelablePromise { return __request(OpenAPI, { method: 'PUT', url: '/api/v1/items/{id}', @@ -260,14 +390,14 @@ export class UsersService { } /** - * Create User + * Create User Endpoint * Create new user. * @param data The data for the request. * @param data.requestBody * @returns UserPublic Successful Response * @throws ApiError */ - public static createUser(data: UsersCreateUserData): CancelablePromise { + public static createUserEndpoint(data: UsersCreateUserEndpointData): CancelablePromise { return __request(OpenAPI, { method: 'POST', url: '/api/v1/users/', @@ -387,7 +517,7 @@ export class UsersService { } /** - * Update User + * Update User Endpoint * Update a user. * @param data The data for the request. * @param data.userId @@ -395,7 +525,7 @@ export class UsersService { * @returns UserPublic Successful Response * @throws ApiError */ - public static updateUser(data: UsersUpdateUserData): CancelablePromise { + public static updateUserEndpoint(data: UsersUpdateUserEndpointData): CancelablePromise { return __request(OpenAPI, { method: 'PATCH', url: '/api/v1/users/{user_id}', diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba34c2..5d032f8e6f 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1,5 +1,9 @@ // This file is auto-generated by @hey-api/openapi-ts +export type Body_files_upload_file_endpoint = { + file: (Blob | File); +}; + export type Body_login_login_access_token = { grant_type?: (string | null); username: string; @@ -9,6 +13,22 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type FilePublic = { + filename: string; + content_type: string; + size?: (number | null); + job_id?: (string | null); + job_status?: (string | null); + id: string; + created_at?: (string | null); + user_id: string; +}; + +export type FilesPublic = { + data: Array; + count: number; +}; + export type HTTPValidationError = { detail?: Array; }; @@ -22,7 +42,7 @@ export type ItemPublic = { title: string; description?: (string | null); id: string; - owner_id: string; + user_id: string; created_at?: (string | null); }; @@ -45,6 +65,17 @@ export type NewPassword = { new_password: string; }; +export type PresignRequest = { + filename: string; + content_type?: (string | null); + bucket?: (string | null); +}; + +export type PresignResponse = { + url: string; + key: string; +}; + export type PrivateUserCreate = { email: string; password: string; @@ -64,18 +95,18 @@ export type UpdatePassword = { export type UserCreate = { email: string; + password: string; + full_name?: (string | null); is_active?: boolean; is_superuser?: boolean; - full_name?: (string | null); - password: string; }; export type UserPublic = { + id: string; email: string; is_active?: boolean; is_superuser?: boolean; full_name?: (string | null); - id: string; created_at?: (string | null); }; @@ -92,10 +123,10 @@ export type UsersPublic = { export type UserUpdate = { email?: (string | null); - is_active?: boolean; - is_superuser?: boolean; - full_name?: (string | null); password?: (string | null); + full_name?: (string | null); + is_active?: (boolean | null); + is_superuser?: (boolean | null); }; export type UserUpdateMe = { @@ -113,6 +144,44 @@ export type ValidationError = { }; }; +export type FilesUploadFileEndpointData = { + formData: Body_files_upload_file_endpoint; +}; + +export type FilesUploadFileEndpointResponse = (FilePublic); + +export type FilesListFilesData = { + limit?: number; + skip?: number; +}; + +export type FilesListFilesResponse = (FilesPublic); + +export type FilesPresignUploadData = { + requestBody: PresignRequest; +}; + +export type FilesPresignUploadResponse = (PresignResponse); + +export type FilesUpdateFileJobStatusEndpointData = { + fileId: string; + jobStatus: string; +}; + +export type FilesUpdateFileJobStatusEndpointResponse = (unknown); + +export type FilesGetFileStatusData = { + fileId: string; +}; + +export type FilesGetFileStatusResponse = (FilePublic); + +export type FilesDownloadTableExcelFileData = { + fileId: string; +}; + +export type FilesDownloadTableExcelFileResponse = Blob; + export type ItemsReadItemsData = { limit?: number; skip?: number; @@ -120,11 +189,11 @@ export type ItemsReadItemsData = { export type ItemsReadItemsResponse = (ItemsPublic); -export type ItemsCreateItemData = { +export type ItemsCreateItemEndpointData = { requestBody: ItemCreate; }; -export type ItemsCreateItemResponse = (ItemPublic); +export type ItemsCreateItemEndpointResponse = (ItemPublic); export type ItemsReadItemData = { id: string; @@ -132,12 +201,12 @@ export type ItemsReadItemData = { export type ItemsReadItemResponse = (ItemPublic); -export type ItemsUpdateItemData = { +export type ItemsUpdateItemEndpointData = { id: string; requestBody: ItemUpdate; }; -export type ItemsUpdateItemResponse = (ItemPublic); +export type ItemsUpdateItemEndpointResponse = (ItemPublic); export type ItemsDeleteItemData = { id: string; @@ -184,11 +253,11 @@ export type UsersReadUsersData = { export type UsersReadUsersResponse = (UsersPublic); -export type UsersCreateUserData = { +export type UsersCreateUserEndpointData = { requestBody: UserCreate; }; -export type UsersCreateUserResponse = (UserPublic); +export type UsersCreateUserEndpointResponse = (UserPublic); export type UsersReadUserMeResponse = (UserPublic); @@ -218,12 +287,12 @@ export type UsersReadUserByIdData = { export type UsersReadUserByIdResponse = (UserPublic); -export type UsersUpdateUserData = { +export type UsersUpdateUserEndpointData = { requestBody: UserUpdate; userId: string; }; -export type UsersUpdateUserResponse = (UserPublic); +export type UsersUpdateUserEndpointResponse = (UserPublic); export type UsersDeleteUserData = { userId: string; diff --git a/frontend/src/components/FileHistoryTable.tsx b/frontend/src/components/FileHistoryTable.tsx index ed3940acf5..0e2ad3f434 100644 --- a/frontend/src/components/FileHistoryTable.tsx +++ b/frontend/src/components/FileHistoryTable.tsx @@ -1,5 +1,8 @@ -import { FileText, Download, Eye } from "lucide-react" +import { useSuspenseQuery } from "@tanstack/react-query" +import { Download, Eye, FileText } from "lucide-react" +import { FilesService } from "@/client" import { Button } from "@/components/ui/button" +import { Empty } from "@/components/ui/empty" import { Table, TableBody, @@ -9,15 +12,27 @@ import { TableRow, } from "@/components/ui/table" import { StatusBadge } from "./StatusBadge" -import { Empty } from "@/components/ui/empty" -import type { FileHistoryItem } from "@/lib/mock-data" +import dayjs from "dayjs" +import { DateTimeFormat } from "@/utils" -interface FileHistoryTableProps { - files: FileHistoryItem[] +function getRecentUploadFilesQueryOptions() { + return { + queryFn: () => FilesService.listFiles({ skip: 0, limit: 5 }), + queryKey: ["files-recent-upload"], + } } -export function FileHistoryTable({ files }: FileHistoryTableProps) { - if (files.length === 0) { +const fileSizeInMB = (sizeInBytes: number | null | undefined) => { + if (sizeInBytes == null) return "N/A"; + return (sizeInBytes / (1024 * 1024)).toFixed(2) + " MB" +} + +export function FileHistoryTable() { + const { data: recentFiles } = useSuspenseQuery( + getRecentUploadFilesQueryOptions(), + ) + + if (recentFiles.count === 0) { return ( @@ -35,7 +50,6 @@ export function FileHistoryTable({ files }: FileHistoryTableProps) { Filename - Bank Size Status Date @@ -43,7 +57,7 @@ export function FileHistoryTable({ files }: FileHistoryTableProps) { - {files.map((file) => ( + {recentFiles.data.map((file) => (
@@ -51,17 +65,25 @@ export function FileHistoryTable({ files }: FileHistoryTableProps) { {file.filename}
- {file.bankType} - {file.size} + {fileSizeInMB(file.size)} - + - {file.uploadDate} + {dayjs(file.created_at).format(DateTimeFormat)}
- {file.status === "completed" && ( + {file.job_status === "done" && ( <> - - -

- Supported formats: PDF, CSV, TXT (Max 10MB) -

-
- ) : ( -
-
- +
+ + {!selection ? ( +
+
+ +
+

Upload bank statement

+

+ Drag and drop a file or folder here, or click to browse +

+
+ {/* Single-file picker */} + + + {/* Folder picker */} + +
+

+ Accepted: images (JPEG, PNG, WebP, …) or PDF · Max 100 MB per + file +
+ Folders must contain only images or only PDFs +

-

- {selectedFile.name} -

-

- {(selectedFile.size / 1024).toFixed(2)} KB -

-
- - + ) : ( +
+
+ {selection.folderName ? ( + + ) : ( + + )} +
+ +

+ {selection.folderName ?? selection.files[0].name} +

+ +

+ {selection.folderName + ? `${selection.files.length} file${selection.files.length !== 1 ? "s" : ""}` + : null} +

+ +

+ {(totalSize / 1024).toFixed(2)} KB total +

+ +
+ +
-
+ )} +
+ + {error && ( +

+ + {error} +

)} - +
) } diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx new file mode 100644 index 0000000000..1551777774 --- /dev/null +++ b/frontend/src/components/Files/columns.tsx @@ -0,0 +1,159 @@ +import type { ColumnDef } from "@tanstack/react-table" +import { Check, DownloadIcon, Loader2, RefreshCcw } from "lucide-react" +import { useState } from "react" + +import { type FilePublic, FilesService } from "@/client" +import { OpenAPI } from "@/client/core/OpenAPI" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { StatusBadge } from "../StatusBadge" +import dayjs from "dayjs" +import { DateTimeFormat } from "@/utils" +async function downloadExcel(fileId: string, filename: string) { + const token = + typeof OpenAPI.TOKEN === "function" + ? await OpenAPI.TOKEN({} as never) + : OpenAPI.TOKEN + + const base = OpenAPI.BASE || "" + const response = await fetch(`${base}/api/v1/files/${fileId}/download`, { + method: "POST", + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) + + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`) + } + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + const safeName = filename.replace(/\.[^.]+$/, "") + a.href = url + a.download = `${safeName}_tables.xlsx` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +function DownloadButton({ file }: { file: FilePublic }) { + const [loading, setLoading] = useState(false) + + const handleDownload = async () => { + setLoading(true) + try { + await downloadExcel(file.id, file.filename) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + + return ( + + ) +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "filename", + header: "File Name", + cell: ({ row }) => ( + {row.original.filename} + ), + }, + { + accessorKey: "content_type", + header: "Content Type", + cell: ({ row }) => { + const contentType = row.original.content_type + return ( + + {contentType || "No content type provided"} + + ) + }, + }, + { + id: "state", + header: "State", + cell: ({ row }) => { + const state = row.original.job_status as + | "pending" + | "running" + | "done" + | "failed" + | undefined + + return + }, + }, + { + id: "created_at", + header: "Uploaded At", + cell: ({ row }) => { + const file = row.original + return ( +
+ {file.created_at + ? dayjs(file.created_at).format(DateTimeFormat) + : "Unknown"} +
+ ) + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const file = row.original + return ( +
+ {(file.job_status === "running" || file.job_status === "pending") && ( + + )} + {file.job_status === "done" && ( + <> +
+ +
+ + + )} +
+ ) + }, + }, +] diff --git a/frontend/src/components/Items/columns.tsx b/frontend/src/components/Items/columns.tsx deleted file mode 100644 index b41be2a70d..0000000000 --- a/frontend/src/components/Items/columns.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table" -import { Check, Copy } from "lucide-react" - -import type { ItemPublic } from "@/client" -import { Button } from "@/components/ui/button" -import { useCopyToClipboard } from "@/hooks/useCopyToClipboard" -import { cn } from "@/lib/utils" -import { ItemActionsMenu } from "./ItemActionsMenu" - -function CopyId({ id }: { id: string }) { - const [copiedText, copy] = useCopyToClipboard() - const isCopied = copiedText === id - - return ( -
- {id} - -
- ) -} - -export const columns: ColumnDef[] = [ - { - accessorKey: "id", - header: "ID", - cell: ({ row }) => , - }, - { - accessorKey: "title", - header: "Title", - cell: ({ row }) => ( - {row.original.title} - ), - }, - { - accessorKey: "description", - header: "Description", - cell: ({ row }) => { - const description = row.original.description - return ( - - {description || "No description"} - - ) - }, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => ( -
- -
- ), - }, -] diff --git a/frontend/src/components/PricingCard.tsx b/frontend/src/components/PricingCard.tsx index 1976d25bb3..f93869e8e4 100644 --- a/frontend/src/components/PricingCard.tsx +++ b/frontend/src/components/PricingCard.tsx @@ -1,7 +1,7 @@ import { Link } from "@tanstack/react-router" +import { Check } from "lucide-react" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" -import { Check } from "lucide-react" import type { PricingTier } from "@/lib/mock-data" interface PricingCardProps { diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 8502bcb9a4..a0838c914d 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Briefcase, Home, Users } from "lucide-react" +import { Briefcase, Files, Home, Users } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -13,8 +13,8 @@ import { type Item, Main } from "./Main" import { User } from "./User" const baseItems: Item[] = [ - { icon: Home, title: "Dashboard", path: "/" }, - { icon: Briefcase, title: "Items", path: "/items" }, + { icon: Home, title: "Dashboard", path: "/dashboard" }, + { icon: Files, title: "Files", path: "/files" }, ] export function AppSidebar() { diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx index bcb1a4b2d1..87daee5a4c 100644 --- a/frontend/src/components/StatusBadge.tsx +++ b/frontend/src/components/StatusBadge.tsx @@ -1,8 +1,8 @@ -import { CheckCircle2, Clock, AlertCircle, HardDrive } from "lucide-react" +import { AlertCircle, CheckCircle2, Circle, Clock } from "lucide-react" import { Badge } from "@/components/ui/badge" interface StatusBadgeProps { - status: "pending" | "processing" | "completed" | "error" + status: "pending" | "running" | "done" | "failed" | undefined className?: string } @@ -11,35 +11,43 @@ const statusConfig = { icon: Clock, label: "Pending", variant: "outline" as const, + className: "text-yellow-600", }, - processing: { - icon: HardDrive, + running: { + icon: Circle, label: "Processing", variant: "default" as const, + className: "text-blue-600", }, - completed: { + done: { icon: CheckCircle2, label: "Completed", variant: "secondary" as const, + className: "text-green-600", }, - error: { + failed: { icon: AlertCircle, label: "Error", variant: "destructive" as const, + className: "text-red-600", }, } export function StatusBadge({ status, className }: StatusBadgeProps) { + if (!status) { + return null + } + const config = statusConfig[status] const Icon = config.icon return ( {config.label} diff --git a/frontend/src/components/loading-spinner-provider.tsx b/frontend/src/components/loading-spinner-provider.tsx new file mode 100644 index 0000000000..d4e032b0ab --- /dev/null +++ b/frontend/src/components/loading-spinner-provider.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext, useState } from "react" +import { Spinner } from "./ui/spinner" +import { cn } from "@/lib/utils" + +type LoadingSpinnerContextType = { + isLoading: boolean + showSpinner: (text: string) => void + hideSpinner: () => void +} + +const LoadingSpinnerContext = createContext( + null, +) + +export function LoadingSpinnerProvider({ + children, +}: { + children: React.ReactNode +}) { + const [isLoading, setIsLoading] = useState(false) + const [text, setText] = useState(null) + + const showSpinner = (text: string) => { + setText(text) + setIsLoading(true) + } + + const hideSpinner = () => setIsLoading(false) + + return ( + + {children} + {isLoading && ( +
+ + {text &&

{text}

} +
+ )} +
+ ) +} + +export function useLoadingSpinner() { + const context = useContext(LoadingSpinnerContext) + if (!context) { + throw new Error( + "useLoadingSpinner must be used within a LoadingSpinnerProvider", + ) + } + return context +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 7ccc795c48..d9602d04bc 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -48,14 +48,14 @@ const useAuth = () => { const loginMutation = useMutation({ mutationFn: login, onSuccess: () => { - navigate({ to: "/" }) + navigate({ to: "/dashboard" }) }, onError: handleError.bind(showErrorToast), }) const logout = () => { localStorage.removeItem("access_token") - navigate({ to: "/login" }) + navigate({ to: "/home" }) } return { diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index 32f120d227..174b29c795 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -3,7 +3,7 @@ export interface FileHistoryItem { filename: string uploadDate: string size: string - status: "pending" | "processing" | "completed" | "error" + status: "pending" | "running" | "done" | "failed" bankType: string } @@ -22,7 +22,7 @@ export const mockFileHistory: FileHistoryItem[] = [ filename: "Vietcombank_Statement_May_2024.pdf", uploadDate: "2024-05-15", size: "2.4 MB", - status: "completed" as const, + status: "done" as const, bankType: "Vietcombank", }, { @@ -30,7 +30,7 @@ export const mockFileHistory: FileHistoryItem[] = [ filename: "Techcombank_April_Statement.pdf", uploadDate: "2024-04-28", size: "1.8 MB", - status: "completed" as const, + status: "done" as const, bankType: "Techcombank", }, { @@ -38,7 +38,7 @@ export const mockFileHistory: FileHistoryItem[] = [ filename: "ACB_Statement_June.pdf", uploadDate: "2024-06-10", size: "3.1 MB", - status: "processing" as const, + status: "running" as const, bankType: "ACB", }, { @@ -46,7 +46,7 @@ export const mockFileHistory: FileHistoryItem[] = [ filename: "BIDV_May_Statement.pdf", uploadDate: "2024-05-20", size: "2.7 MB", - status: "completed" as const, + status: "done" as const, bankType: "BIDV", }, ] diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8afe946cb5..6c89a2adba 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { createRouter, RouterProvider } from "@tanstack/react-router" import { StrictMode } from "react" import ReactDOM from "react-dom/client" import { ApiError, OpenAPI } from "./client" +import { LoadingSpinnerProvider } from "./components/loading-spinner-provider" import { ThemeProvider } from "./components/theme-provider" import { Toaster } from "./components/ui/sonner" import "./index.css" @@ -43,10 +44,12 @@ declare module "@tanstack/react-router" { ReactDOM.createRoot(document.getElementById("root")!).render( - - - - + + + + + + , ) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 5b2ec0184c..2b42ebd6db 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,15 +13,16 @@ import { Route as SignupRouteImport } from './routes/signup' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as RecoverPasswordRouteImport } from './routes/recover-password' import { Route as LoginRouteImport } from './routes/login' +import { Route as PublicRouteImport } from './routes/_public' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' +import { Route as PublicPricingRouteImport } from './routes/_public/pricing' +import { Route as PublicHomeRouteImport } from './routes/_public/home' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' +import { Route as LayoutFilesRouteImport } from './routes/_layout/files' +import { Route as LayoutDashboardRouteImport } from './routes/_layout/dashboard' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' -import { Route as PublicRouteImport } from './routes/_public' -import { Route as PublicIndexRouteImport } from './routes/_public/index' -import { Route as PublicDashboardRouteImport } from './routes/_public/trial' -import { Route as PublicPricingRouteImport } from './routes/_public/pricing' const SignupRoute = SignupRouteImport.update({ id: '/signup', @@ -43,15 +44,29 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const PublicRoute = PublicRouteImport.update({ + id: '/_public', + getParentRoute: () => rootRouteImport, +} as any) const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) const LayoutIndexRoute = LayoutIndexRouteImport.update({ - id: '/_layout/', + id: '/', path: '/', getParentRoute: () => LayoutRoute, } as any) +const PublicPricingRoute = PublicPricingRouteImport.update({ + id: '/pricing', + path: '/pricing', + getParentRoute: () => PublicRoute, +} as any) +const PublicHomeRoute = PublicHomeRouteImport.update({ + id: '/home', + path: '/home', + getParentRoute: () => PublicRoute, +} as any) const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -62,53 +77,48 @@ const LayoutItemsRoute = LayoutItemsRouteImport.update({ path: '/items', getParentRoute: () => LayoutRoute, } as any) -const LayoutAdminRoute = LayoutAdminRouteImport.update({ - id: '/admin', - path: '/admin', +const LayoutFilesRoute = LayoutFilesRouteImport.update({ + id: '/files', + path: '/files', getParentRoute: () => LayoutRoute, } as any) -const PublicRoute = PublicRouteImport.update({ - id: '/_public', - getParentRoute: () => rootRouteImport, -} as any) -const PublicIndexRoute = PublicIndexRouteImport.update({ - id: '/_public/', - path: '/', - getParentRoute: () => PublicRoute, -} as any) -const PublicDashboardRoute = PublicDashboardRouteImport.update({ - id: '/_public/dashboard', +const LayoutDashboardRoute = LayoutDashboardRouteImport.update({ + id: '/dashboard', path: '/dashboard', - getParentRoute: () => PublicRoute, + getParentRoute: () => LayoutRoute, } as any) -const PublicPricingRoute = PublicPricingRouteImport.update({ - id: '/_public/pricing', - path: '/pricing', - getParentRoute: () => PublicRoute, +const LayoutAdminRoute = LayoutAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => LayoutRoute, } as any) export interface FileRoutesByFullPath { - '/': typeof PublicIndexRoute + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/dashboard': typeof LayoutDashboardRoute + '/files': typeof LayoutFilesRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/dashboard': typeof PublicDashboardRoute + '/home': typeof PublicHomeRoute '/pricing': typeof PublicPricingRoute } export interface FileRoutesByTo { + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/dashboard': typeof LayoutDashboardRoute + '/files': typeof LayoutFilesRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof PublicIndexRoute - '/dashboard': typeof PublicDashboardRoute + '/home': typeof PublicHomeRoute '/pricing': typeof PublicPricingRoute } export interface FileRoutesById { @@ -120,12 +130,13 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute + '/_layout/dashboard': typeof LayoutDashboardRoute + '/_layout/files': typeof LayoutFilesRoute '/_layout/items': typeof LayoutItemsRoute '/_layout/settings': typeof LayoutSettingsRoute - '/_layout/': typeof LayoutIndexRoute - '/_public/': typeof PublicIndexRoute - '/_public/dashboard': typeof PublicDashboardRoute + '/_public/home': typeof PublicHomeRoute '/_public/pricing': typeof PublicPricingRoute + '/_layout/': typeof LayoutIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -136,21 +147,25 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/dashboard' + | '/files' | '/items' | '/settings' - | '/dashboard' + | '/home' | '/pricing' fileRoutesByTo: FileRoutesByTo to: + | '/' | '/login' | '/recover-password' | '/reset-password' | '/signup' | '/admin' + | '/dashboard' + | '/files' | '/items' | '/settings' - | '/' - | '/dashboard' + | '/home' | '/pricing' id: | '__root__' @@ -161,12 +176,13 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/_layout/admin' + | '/_layout/dashboard' + | '/_layout/files' | '/_layout/items' | '/_layout/settings' - | '/_layout/' - | '/_public/' - | '/_public/dashboard' + | '/_public/home' | '/_public/pricing' + | '/_layout/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -208,10 +224,17 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/_public': { + id: '/_public' + path: '' + fullPath: '/' + preLoaderRoute: typeof PublicRouteImport + parentRoute: typeof rootRouteImport + } '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -222,6 +245,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } + '/_public/pricing': { + id: '/_public/pricing' + path: '/pricing' + fullPath: '/pricing' + preLoaderRoute: typeof PublicPricingRouteImport + parentRoute: typeof PublicRoute + } + '/_public/home': { + id: '/_public/home' + path: '/home' + fullPath: '/home' + preLoaderRoute: typeof PublicHomeRouteImport + parentRoute: typeof PublicRoute + } '/_layout/settings': { id: '/_layout/settings' path: '/settings' @@ -236,6 +273,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutItemsRouteImport parentRoute: typeof LayoutRoute } + '/_layout/files': { + id: '/_layout/files' + path: '/files' + fullPath: '/files' + preLoaderRoute: typeof LayoutFilesRouteImport + parentRoute: typeof LayoutRoute + } + '/_layout/dashboard': { + id: '/_layout/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof LayoutDashboardRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/admin': { id: '/_layout/admin' path: '/admin' @@ -243,39 +294,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } - '/_public': { - id: '/_public' - path: '' - fullPath: '' - preLoaderRoute: typeof PublicRouteImport - parentRoute: typeof rootRouteImport - } - '/_public/': { - id: '/_public/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof PublicIndexRouteImport - parentRoute: typeof PublicRoute - } - '/_public/dashboard': { - id: '/_public/dashboard' - path: '/dashboard' - fullPath: '/dashboard' - preLoaderRoute: typeof PublicDashboardRouteImport - parentRoute: typeof PublicRoute - } - '/_public/pricing': { - id: '/_public/pricing' - path: '/pricing' - fullPath: '/pricing' - preLoaderRoute: typeof PublicPricingRouteImport - parentRoute: typeof PublicRoute - } } } interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute + LayoutDashboardRoute: typeof LayoutDashboardRoute + LayoutFilesRoute: typeof LayoutFilesRoute LayoutItemsRoute: typeof LayoutItemsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute @@ -283,6 +308,8 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, + LayoutDashboardRoute: LayoutDashboardRoute, + LayoutFilesRoute: LayoutFilesRoute, LayoutItemsRoute: LayoutItemsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, @@ -292,14 +319,12 @@ const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) interface PublicRouteChildren { - PublicIndexRoute: typeof PublicIndexRoute - PublicDashboardRoute: typeof PublicDashboardRoute + PublicHomeRoute: typeof PublicHomeRoute PublicPricingRoute: typeof PublicPricingRoute } const PublicRouteChildren: PublicRouteChildren = { - PublicIndexRoute: PublicIndexRoute, - PublicDashboardRoute: PublicDashboardRoute, + PublicHomeRoute: PublicHomeRoute, PublicPricingRoute: PublicPricingRoute, } diff --git a/frontend/src/routes/_layout/dashboard.tsx b/frontend/src/routes/_layout/dashboard.tsx new file mode 100644 index 0000000000..a66096af9f --- /dev/null +++ b/frontend/src/routes/_layout/dashboard.tsx @@ -0,0 +1,170 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import { useState } from "react"; +import { FilesService } from "@/client"; +import { FileHistoryTable } from "@/components/FileHistoryTable"; +import { FileUploadDropzone } from "@/components/FileUploadDropzone"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import useCustomToast from "@/hooks/useCustomToast"; +import { handleError } from "@/utils"; +import { useLoadingSpinner } from "@/components/loading-spinner-provider"; + +export const Route = createFileRoute("/_layout/dashboard")({ + component: Dashboard, +}); + +function Dashboard() { + const queryClient = useQueryClient(); + const { showSuccessToast, showErrorToast } = useCustomToast(); + const { showSpinner, hideSpinner } = useLoadingSpinner(); + const [selectedFiles, setSelectedFiles] = useState([]); + + const uploadMutation = useMutation({ + mutationFn: async (files: File[]) => { + for (const file of files) { + await FilesService.uploadFileEndpoint({ formData: { file } }); + } + }, + onSuccess: () => { + const count = selectedFiles.length; + showSuccessToast( + count === 1 + ? "File uploaded successfully." + : `${count} files uploaded successfully.`, + ); + setSelectedFiles([]); + queryClient.invalidateQueries({ queryKey: ["files"] }); + }, + onError: handleError.bind(showErrorToast), + }); + + const handleFilesSelect = (files: File[]) => { + setSelectedFiles(files); + }; + + const handleUpload = () => { + if (selectedFiles.length === 0) return; + showSpinner("Uploading and processing your file..."); + uploadMutation.mutate(selectedFiles, { + onSettled: () => { + hideSpinner(); + }, + }); + }; + + return ( +
+
+ {/* Header */} + {/*
+

Dashboard

+

+ Upload and convert your bank statements to Excel +

+
*/} + +
+ {/* Upload Section */} +
+ +

+ Convert Your Statement +

+ +
+ {/* File Upload */} +
+ {/* */} + +
+ + {/* Convert Button */} + + + {/* Info Box */} +
+

💡 Pro Tip:

+

+ Upload multiple statements to process them together. Our + system will automatically organize all transactions + chronologically. +

+
+
+
+ + {/* Recent Conversions */} +
+

Recent Conversions

+ + + +
+
+ + {/* Sidebar Stats */} +
+ +
+

12

+

+ Files Processed This Month +

+
+
+
+

+ 60% of monthly quota +

+
+ + + +

Quick Stats

+
+
+ Total Files + 48 +
+
+ Total Transactions + 2,847 +
+
+ Storage Used + 245 MB +
+
+
+ + {/* +

Upgrade to Pro

+

+ Get unlimited conversions and advanced features +

+ +
*/} +
+
+
+
+ ); +} diff --git a/frontend/src/routes/_layout/files.tsx b/frontend/src/routes/_layout/files.tsx new file mode 100644 index 0000000000..0a89a9c730 --- /dev/null +++ b/frontend/src/routes/_layout/files.tsx @@ -0,0 +1,104 @@ +import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { Search } from "lucide-react" +import { Suspense, useEffect, useRef } from "react" + +import { FilesService } from "@/client" +import { DataTable } from "@/components/Common/DataTable" +import { columns } from "@/components/Files/columns" +import PendingItems from "@/components/Pending/PendingItems" + +function getFilesQueryOptions(limit = 0) { + return { + queryFn: () => FilesService.listFiles({ skip: 0, limit }), + queryKey: ["files"], + refetchInterval: 3000, // Refetch every 5 seconds to get real-time updates + } +} + + + +export const Route = createFileRoute("/_layout/files")({ + component: Files, + head: () => ({ + meta: [ + { + title: "Files - FastAPI Template", + }, + ], + }), +}) + +function FilesTableContent() { + const queryClient = useQueryClient() + const { data: files } = useSuspenseQuery(getFilesQueryOptions()) + const pollingRef = useRef | null>(null) + + useEffect(() => { + const pollPendingFiles = async () => { + const pendingFiles = files.data.filter( + (f) => f.job_status !== "done" && f.job_status !== "failed", + ) + + if (pendingFiles.length === 0) { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + return + } + + await Promise.allSettled( + pendingFiles.map(async (file) => { + const updated = await FilesService.getFileStatus({ fileId: file.id }) + queryClient.setQueryData(["files"], (old: typeof files | undefined) => { + if (!old) return old + return { + ...old, + data: old.data.map((f) => (f.id === updated.id ? updated : f)), + } + }) + }), + ) + } + + pollingRef.current = setInterval(pollPendingFiles, 3000) + + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + } + }, [files.data, queryClient]) + + if (files.data.length === 0) { + return ( +
+
+ +
+

You don't have any items yet

+

Add a new item to get started

+
+ ) + } + + return +} + +function FilesTable() { + return ( + }> + + + ) +} + +function Files() { + return ( +
+ +
+ ) +} diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 3e640cbbb8..a13098bf1d 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -1,31 +1,327 @@ -import { createFileRoute } from "@tanstack/react-router" - -import useAuth from "@/hooks/useAuth" +import { createFileRoute, Link } from "@tanstack/react-router" +import { ArrowRight, Check, Clock, FileText, Zap } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" export const Route = createFileRoute("/_layout/")({ - component: Dashboard, + component: Home, head: () => ({ - meta: [ - { - title: "Dashboard - FastAPI Template", - }, - ], + meta: [{ title: "Trial - KeToanAuto" }], }), }) -function Dashboard() { - const { user: currentUser } = useAuth() +function Home() { + const features = [ + { + icon: Zap, + title: "Lightning Fast", + description: + "Convert your bank statements to Excel in seconds, not minutes", + }, + { + icon: Lock, + title: "Secure & Private", + description: + "Your data is encrypted and deleted immediately after conversion", + }, + { + icon: Clock, + title: "Batch Processing", + description: "Upload multiple files and process them simultaneously", + }, + { + icon: FileText, + title: "Auto-Detection", + description: + "Automatically detects and formats data from any bank statement", + }, + ] + + const benefits = [ + "Supports all major Vietnamese banks", + "Customizable Excel templates", + "Transaction categorization", + "Monthly reconciliation reports", + "API integration available", + "Dedicated support", + ] return ( -
-
-

- Hi, {currentUser?.full_name || currentUser?.email} 👋 -

-

- Welcome back, nice to see you again!!! -

-
-
+ <> + {/* Hero Section */} +
+
+
+

+ 🚀 New feature: Batch processing for unlimited files +

+
+ +

+ Convert Bank Statements to Excel in Seconds +

+ +

+ Stop wasting time manually copying bank transactions. BankToExcel + converts your statements to perfectly formatted Excel files + instantly. Built for accountants in Vietnam. +

+ +
+ + + + +
+ +
+

+ Trusted by accountants and finance teams +

+
+ {["Vietcombank", "Techcombank", "BIDV", "VietinBank", "ACB"].map( + (bank) => ( +
+ {bank} +
+ ), + )} +
+
+
+
+ + {/* Features Section */} +
+
+
+

+ Why choose BankToExcel? +

+

+ Designed specifically for Vietnamese accountants and finance + professionals +

+
+ +
+ {features.map((feature) => { + const _Icon = feature.icon + return ( + + {/* */} +

+ {feature.title} +

+

{feature.description}

+
+ ) + })} +
+
+
+ + {/* Benefits Section */} +
+
+
+

+ Everything you need for efficient accounting +

+

+ From individual freelancers to large accounting firms, BankToExcel + provides all the tools you need to streamline your workflow. +

+
    + {benefits.map((benefit) => ( +
  • + + {benefit} +
  • + ))} +
+
+ +
+
+
+

+ Original PDF Statement +

+
+

+ Vietcombank Statement - May 2024 +

+

Account: ***4567

+

+ Opening Balance: 50,000,000 VND +

+
+
+
+ +
+
+

+ Converted Excel File +

+
+

+ 📊 transactions.xlsx +

+

+ ✓ Formatted columns +

+

+ ✓ Categorized entries +

+
+
+
+
+
+
+ + {/* Stats Section */} +
+
+
+
+

+ 500+ +

+

Files Converted Daily

+
+
+

+ 98% +

+

Customer Satisfaction

+
+
+

+ <5s +

+

Average Processing Time

+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to simplify your accounting? +

+

+ Join hundreds of accountants and finance teams saving hours every + week +

+
+ + + + + + +
+
+
+ + {/* Footer */} +
+
+
+
+

Product

+
    +
  • + + Features + +
  • +
  • + + Pricing + +
  • +
  • + + Security + +
  • +
+
+
+

Company

+
    +
  • + + About + +
  • +
  • + + Blog + +
  • +
  • + + Contact + +
  • +
+
+
+

Legal

+
    +
  • + + Privacy + +
  • +
  • + + Terms + +
  • +
+
+
+

Support

+
    +
  • + + Help Center + +
  • +
  • + + Status + +
  • +
+
+
+ +
+

+ © 2024 BankToExcel. All rights reserved. +

+

+ Made for accountants in Vietnam 🇻🇳 +

+
+
+
+ ) } diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx index a4df200023..043a8872cd 100644 --- a/frontend/src/routes/_layout/items.tsx +++ b/frontend/src/routes/_layout/items.tsx @@ -3,16 +3,16 @@ import { createFileRoute } from "@tanstack/react-router" import { Search } from "lucide-react" import { Suspense } from "react" -import { ItemsService } from "@/client" +import { FilesService } from "@/client" import { DataTable } from "@/components/Common/DataTable" +import { columns } from "@/components/Files/columns" import AddItem from "@/components/Items/AddItem" -import { columns } from "@/components/Items/columns" import PendingItems from "@/components/Pending/PendingItems" -function getItemsQueryOptions() { +function getFilesQueryOptions() { return { - queryFn: () => ItemsService.readItems({ skip: 0, limit: 100 }), - queryKey: ["items"], + queryFn: () => FilesService.listFiles({ skip: 0, limit: 0 }), + queryKey: ["files"], } } @@ -28,7 +28,7 @@ export const Route = createFileRoute("/_layout/items")({ }) function ItemsTableContent() { - const { data: items } = useSuspenseQuery(getItemsQueryOptions()) + const { data: items } = useSuspenseQuery(getFilesQueryOptions()) if (items.data.length === 0) { return ( diff --git a/frontend/src/routes/_public/index.tsx b/frontend/src/routes/_public/home.tsx similarity index 93% rename from frontend/src/routes/_public/index.tsx rename to frontend/src/routes/_public/home.tsx index a23c6fdf0b..a21e70c485 100644 --- a/frontend/src/routes/_public/index.tsx +++ b/frontend/src/routes/_public/home.tsx @@ -1,9 +1,9 @@ import { createFileRoute, Link } from "@tanstack/react-router" +import { ArrowRight, Check, Clock, FileText, Lock, Zap } from "lucide-react" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" -import { ArrowRight, Zap, Lock, Clock, FileText, Check } from "lucide-react" -export const Route = createFileRoute("/_public/")({ +export const Route = createFileRoute("/_public/home")({ component: Home, head: () => ({ meta: [{ title: "BankToExcel - Convert Bank Statements to Excel" }], @@ -15,12 +15,14 @@ function Home() { { icon: Zap, title: "Lightning Fast", - description: "Convert your bank statements to Excel in seconds, not minutes", + description: + "Convert your bank statements to Excel in seconds, not minutes", }, { icon: Lock, title: "Secure & Private", - description: "Your data is encrypted and deleted immediately after conversion", + description: + "Your data is encrypted and deleted immediately after conversion", }, { icon: Clock, @@ -30,7 +32,8 @@ function Home() { { icon: FileText, title: "Auto-Detection", - description: "Automatically detects and formats data from any bank statement", + description: + "Automatically detects and formats data from any bank statement", }, ] @@ -60,8 +63,8 @@ function Home() {

Stop wasting time manually copying bank transactions. BankToExcel - converts your statements to perfectly formatted Excel files instantly. - Built for accountants in Vietnam. + converts your statements to perfectly formatted Excel files + instantly. Built for accountants in Vietnam.

@@ -112,7 +115,9 @@ function Home() { return ( -

{feature.title}

+

+ {feature.title} +

{feature.description}

) @@ -169,7 +174,9 @@ function Home() {

📊 transactions.xlsx

-

✓ Formatted columns

+

+ ✓ Formatted columns +

✓ Categorized entries

@@ -213,7 +220,8 @@ function Home() { Ready to simplify your accounting?

- Join hundreds of accountants and finance teams saving hours every week + Join hundreds of accountants and finance teams saving hours every + week

diff --git a/frontend/src/routes/_public/pricing.tsx b/frontend/src/routes/_public/pricing.tsx index 12d0d01a45..c932ade79c 100644 --- a/frontend/src/routes/_public/pricing.tsx +++ b/frontend/src/routes/_public/pricing.tsx @@ -1,9 +1,9 @@ import { createFileRoute, Link } from "@tanstack/react-router" +import { Check } from "lucide-react" +import { PricingCard } from "@/components/PricingCard" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" -import { PricingCard } from "@/components/PricingCard" import { pricingTiers } from "@/lib/mock-data" -import { Check } from "lucide-react" export const Route = createFileRoute("/_public/pricing")({ component: Pricing, @@ -151,7 +151,8 @@ function Pricing() { Simple, Transparent Pricing

- Choose the plan that's right for you. No hidden fees, cancel anytime. + Choose the plan that's right for you. No hidden fees, cancel + anytime.

diff --git a/frontend/src/routes/_public/trial.tsx b/frontend/src/routes/_public/trial.tsx deleted file mode 100644 index a6da576168..0000000000 --- a/frontend/src/routes/_public/trial.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router" -import { useState } from "react" -import { Button } from "@/components/ui/button" -import { Card } from "@/components/ui/card" -import { BankTypeSelect } from "@/components/BankTypeSelect" -import { FileUploadDropzone } from "@/components/FileUploadDropzone" -import { FileHistoryTable } from "@/components/FileHistoryTable" -import { mockFileHistory } from "@/lib/mock-data" -import { ArrowRight } from "lucide-react" - -export const Route = createFileRoute("/_public/trial")({ - component: Trial, - head: () => ({ - meta: [{ title: "Trial - KeToanAuto" }], - }), -}) - -function Trial() { - const [selectedBank, setSelectedBank] = useState("") - const [selectedFile, setSelectedFile] = useState(null) - const [isProcessing, setIsProcessing] = useState(false) - - const handleFileSelect = (file: File) => { - setSelectedFile(file) - } - - const handleConvert = async () => { - if (!selectedFile || !selectedBank) return - setIsProcessing(true) - await new Promise((resolve) => setTimeout(resolve, 2000)) - setIsProcessing(false) - setSelectedFile(null) - setSelectedBank("") - } - - return ( -
- {/* Header */} -
-

Dashboard

-

- Upload and convert your bank statements to Excel -

-
- -
- {/* Upload Section */} -
- -

Convert Your Statement

-
- -
- - -
- -
-

💡 Pro Tip:

-

- Upload multiple statements to process them together. Our system - will automatically organize all transactions chronologically. -

-
-
-
- - {/* Recent Conversions */} -
-

Recent Conversions

- - - -
-
- - {/* Sidebar Stats */} -
- -
-

12

-

- Files Processed This Month -

-
-
-
-

- 60% of monthly quota -

-
- - - -

Quick Stats

-
-
- Total Files - 48 -
-
- Total Transactions - 2,847 -
-
- Storage Used - 245 MB -
-
-
- - -

Upgrade to Pro

-

- Get unlimited conversions and advanced features -

- - - -
-
-
-
- ) -} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index fa491eb2f4..90197c9737 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -29,3 +29,5 @@ export const getInitials = (name: string): string => { .join("") .toUpperCase() } + +export const DateTimeFormat = "hh:mm A, MMMM d, YYYY" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 431a76f8c3..6f2a5712e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.20", "form-data": "4.0.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", @@ -4366,6 +4367,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "peer": true }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8623,6 +8630,11 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "peer": true }, + "dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8852,6 +8864,7 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.20", "dotenv": "^17.3.1", "form-data": "4.0.5", "lucide-react": "^0.563.0", diff --git a/uv.lock b/uv.lock index 50c0d61c1c..dc4a677230 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,9 @@ revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version < '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", ] [manifest] @@ -70,6 +72,8 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "jinja2" }, + { name = "openpyxl" }, + { name = "pandas" }, { name = "psycopg", extra = ["binary"] }, { name = "pwdlib", extra = ["argon2", "bcrypt"] }, { name = "pydantic" }, @@ -99,6 +103,8 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, { name = "httpx", specifier = ">=0.25.1,<1.0.0" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, + { name = "openpyxl", specifier = ">=3.1.0,<4.0.0" }, + { name = "pandas", specifier = ">=2.0.0,<3.0.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" }, { name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" }, { name = "pydantic", specifier = ">2.0" }, @@ -632,12 +638,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/7e/b648d640d88d31de49e566832aca9cce025c52d6349b0a0fc65e9df1f4c5/emails-0.6-py2.py3-none-any.whl", hash = "sha256:72c1e3198075709cc35f67e1b49e2da1a2bc087e9b444073db61a379adfb7f3c", size = 56250, upload-time = "2020-06-19T11:20:40.466Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1380,6 +1395,167 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" }, + { url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" }, + { url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" }, + { url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" }, + { url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" }, + { url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" }, + { url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" }, + { url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" }, + { url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" }, + { url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1389,6 +1565,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + [[package]] name = "pathspec" version = "1.0.3" @@ -1783,6 +2021,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From 519440af19d6091c678f9bd7b1aeaa1fe492d822 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Tue, 31 Mar 2026 00:49:13 +0700 Subject: [PATCH 06/30] update --- .DS_Store | Bin 6148 -> 8196 bytes ...ef8d_add_column_last_name_to_user_model.py | 39 ------ ...608336_add_cascade_delete_relationships.py | 37 ----- .../versions/1b631ae321a9_edit_model.py | 97 ------------- .../versions/312239f38f57_edit_user_model.py | 29 ---- .../versions/315fb0cb1b81_edit_model_1.py | 33 ----- .../versions/3978fdec9e0b_edit_model_3.py | 29 ---- .../versions/998082d0b0f6_edit_model_2.py | 29 ---- ...4c78_add_max_length_for_string_varchar_.py | 69 ---------- .../versions/b27c541d6090_edit_model_6.py | 83 ++++++++++++ .../b6a0bfbb9e6d_remove_relationship.py | 29 ---- .../alembic/versions/c6971f55aa53_test1121.py | 29 ---- ...edit_replace_id_integers_in_all_models_.py | 90 ------------ .../e2412789c190_initialize_models.py | 54 -------- .../versions/e748195f27c0_edit_model_4.py | 33 ----- ...a70289e_add_created_at_to_user_and_item.py | 31 ----- backend/app/api/main.py | 2 + backend/app/aws/client.py | 13 +- backend/app/files/models.py | 10 +- backend/app/files/router.py | 68 ++++++++-- backend/app/files/schemas.py | 7 +- backend/app/files/service.py | 13 +- backend/app/models.py | 6 + backend/app/ocrs/schemas.py | 2 +- backend/app/ocrs/service.py | 71 ++++++---- .../versions/.keep => storages/__init__.py} | 0 backend/app/storages/dependencies.py | 6 + backend/app/storages/exceptions.py | 6 + backend/app/storages/models.py | 37 +++++ backend/app/storages/router.py | 17 +++ backend/app/storages/schemas.py | 22 +++ backend/app/storages/service.py | 69 ++++++++++ backend/app/storages/utils.py | 0 bun.lock | 3 + frontend/public/assets/images/logo_dark.png | Bin 0 -> 224391 bytes frontend/public/assets/images/logo_light.png | Bin 0 -> 253851 bytes frontend/src/client/schemas.gen.ts | 128 ++++++++++++++++++ frontend/src/client/sdk.gen.ts | 81 ++++++++++- frontend/src/client/types.gen.ts | 45 +++++- frontend/src/components/Common/Logo.tsx | 19 +-- frontend/src/components/FileHistoryTable.tsx | 12 +- .../src/components/FileUploadDropzone.tsx | 14 +- frontend/src/components/Files/columns.tsx | 30 ++-- .../src/components/Sidebar/AppSidebar.tsx | 2 +- .../components/loading-spinner-provider.tsx | 2 +- frontend/src/routes/_layout/dashboard.tsx | 99 +++++++++----- frontend/src/routes/_layout/files.tsx | 33 +++-- frontend/src/utils.ts | 10 +- 48 files changed, 758 insertions(+), 780 deletions(-) delete mode 100644 backend/app/alembic/versions/029973b1ef8d_add_column_last_name_to_user_model.py delete mode 100644 backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py delete mode 100644 backend/app/alembic/versions/1b631ae321a9_edit_model.py delete mode 100644 backend/app/alembic/versions/312239f38f57_edit_user_model.py delete mode 100644 backend/app/alembic/versions/315fb0cb1b81_edit_model_1.py delete mode 100644 backend/app/alembic/versions/3978fdec9e0b_edit_model_3.py delete mode 100644 backend/app/alembic/versions/998082d0b0f6_edit_model_2.py delete mode 100755 backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py create mode 100644 backend/app/alembic/versions/b27c541d6090_edit_model_6.py delete mode 100644 backend/app/alembic/versions/b6a0bfbb9e6d_remove_relationship.py delete mode 100644 backend/app/alembic/versions/c6971f55aa53_test1121.py delete mode 100755 backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py delete mode 100644 backend/app/alembic/versions/e2412789c190_initialize_models.py delete mode 100644 backend/app/alembic/versions/e748195f27c0_edit_model_4.py delete mode 100644 backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py rename backend/app/{alembic/versions/.keep => storages/__init__.py} (100%) mode change 100755 => 100644 create mode 100644 backend/app/storages/dependencies.py create mode 100644 backend/app/storages/exceptions.py create mode 100644 backend/app/storages/models.py create mode 100644 backend/app/storages/router.py create mode 100644 backend/app/storages/schemas.py create mode 100644 backend/app/storages/service.py create mode 100644 backend/app/storages/utils.py create mode 100644 frontend/public/assets/images/logo_dark.png create mode 100644 frontend/public/assets/images/logo_light.png diff --git a/.DS_Store b/.DS_Store index 1495e773ad815c30058437203fe56af5aa7c4f45..b74f38acde0fed7c8d45f2e30a686871af5f53a5 100644 GIT binary patch literal 8196 zcmeHM&2Jk;6n~SXcwOgX(>NtWAX)f=)HI|eL_!GFIEjk+N{HQ(s4Zq~Z=8+SJJ#;n zCPYCwgTx=efg@M0{RcR5<-mo1fCDEaPVjrPyXpEPy;MS#x?|0}*_royZ{|0dH<}3% ziDI{Li)e|6G-Q@~2E~}f{hUYAMA)(nDS#(3$)QHcmi3xFqMbA(3?vLB3?vLB3?vLZ z4-DYU=A>P6?gza|nJ|zr@Io@c@gaxIvJlGiK&sGzLR|qMr_iht>bM6eOfHm#P@V@; zL18nvJ%|u0!YhUd;ONh>I%FY~=Yb61Bmy{z(6b0{C_;G$E~nH<6atx)2?GfOqYUuf zJw-J#s7|f;{ax13!<*@S_NKtS=3N1C+N2a?TFWg>@1Af@nOLL{t$LrushDmcSBY(BUZ+t z{ux5e(I)NCr&OUmDp8T{p;p58U3hIr?;#Nb6UKknmSegK zU^PsrJp9~p8nR!MU8mtGY&q!A(ptKzFP)ygy>WXbw{dIzY$bR4&h538-1=J^XJ=_` zG5_Yfd)1cRcI3C11Cj`j8JY>cFG`-{2oF}h7tTxk8qMnuU5{p#zM7eynVr+;uPw}H z=NA{Rzr3{g%JTB7Rehmm)LTuLN4zO*=@#v-xo_2_Q?|Y~_kHs#pDR`U+K}F!+4$l~ zQ{|)so$QkaW$UhMn$Cl!Yc+Nb4?WZ0kJS&V`kYsHrETv@&+;wl96a6qzszVK1lqR|a(kq@a%N7Se9=?D6W z9@8)MJN-$2iz#tkye@8tb@86KBR&wHi9JylN5T@v0e&J{Yz`{1Bs_eKEcQ^qy8@pQ zzZQ8zA&SF9d^=2Hi?^vcsHMU|Tq{K%BY14mHUdbQwgR5PIglK~ zb6o{4A77(E9t;j((+IHJ;9!eBfxINR4j^>+;ZSE{wtT$YRD5yx^2Xs6zu(08O+l{? z8x}_Fk7YHko#7S3+TBwJL4^*+`A|&8Q7Noh51L~SOb)6dt{waPeAen@U|=Z&xqbQj z|FgPD@CgG61J5x7Vrr|rRm1?Jb8vX9eAmt)e~HYA_09vSASl#z98z7!Aus-6h dict: - bucket = bucket or aws_settings.R2_BUCKET_NAME +def upload_file_to_r2(key: str, data: bytes, content_type: str | None = None, presign: bool = False) -> dict: + bucket = aws_settings.R2_BUCKET_NAME if not bucket: raise RuntimeError("S3 bucket not configured") @@ -48,10 +46,9 @@ def upload_file_to_r2(key: str, data: bytes, bucket: str | None = None, content_ if content_type: extra_args["ContentType"] = content_type - logger.info(f"Uploading file to R2 with key {key} and content type {content_type}") resp = client.put_object(Bucket=bucket, Key=key, Body=data, **extra_args) - logger.info(f"Uploaded file to R2 with key {key}") + resp["IsSuccess"] = resp.get("ResponseMetadata", {}).get("HTTPStatusCode", 0) == 200 if presign: resp["PresignedURL"] = generate_presigned_put_url(key=key, bucket=bucket) - + return resp diff --git a/backend/app/files/models.py b/backend/app/files/models.py index 8b75a5ace7..df3bc015d0 100644 --- a/backend/app/files/models.py +++ b/backend/app/files/models.py @@ -1,18 +1,22 @@ from __future__ import annotations -from alembic.util import err -from app.utils import get_datetime_utc + import uuid from datetime import datetime + +from alembic.util import err from sqlalchemy import DateTime from sqlmodel import Field, SQLModel + from app.ocrs.constants import OcrJobStatus +from app.utils import get_datetime_utc + class File(SQLModel, table=True): __tablename__ = "files" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) filename: str = Field(min_length=1, max_length=255) content_type: str = Field(min_length=1, max_length=255) - size: int | None = None + size: int url: str | None = None job_id: str | None = Field(default=None, max_length=255, index=True) job_status: str | None = Field(default=OcrJobStatus.PENDING, max_length=50) diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 5a323aa191..8936fa04fd 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -1,20 +1,24 @@ -from app.backend_pre_start import logger import uuid -from urllib.parse import quote from fastapi import APIRouter, HTTPException, Response, UploadFile +from sqlalchemy import desc from sqlmodel import select from app.aws.client import upload_file_to_r2 from app.aws.config import aws_settings from app.aws.schemas import PresignRequest, PresignResponse +from app.backend_pre_start import logger from app.files.dependencies import CurrentUser, SessionDep from app.files.models import File -from app.files.schemas import FileCreate, FilePublic, FilesPublic -from app.files.service import create_file, delete_file, update_file_job_status, download_excel_file -from app.ocrs.service import get_update_file_ocr, upload_ocr_job, get_job_status - -from sqlalchemy import desc +from app.files.schemas import FileCreate, FilePublic, FilesPublic, FilesStatusRequest +from app.files.service import ( + create_file, + delete_file, + download_excel_file, + update_file_job_info, +) +from app.ocrs.service import get_job_status, update_ocr_job_status, upload_ocr_job +from app.storages.service import increment_storage_stat router = APIRouter(prefix="/files", tags=["files"]) @@ -34,16 +38,22 @@ def upload_file_endpoint( file_result = create_file(session=session, file_in=file_create, user_id=user.id) try: - logger.info(f"Created DB record for file {file_result.id} with name {file_name}") r2_result = upload_file_to_r2( key=user.email + "/" + str(file_result.id) + "/" + file_name, # Use DB record ID for unique key data=file_bytes, content_type=file_type, presign=True ) - upload_ocr_job(session=session, file=file_result, file_url=r2_result["PresignedURL"]) - + if not r2_result.get("IsSuccess"): + delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + raise HTTPException(status_code=500, detail="Failed to upload file to R2") + + (is_success, msg) = upload_ocr_job(session=session, file=file_result, file_url=r2_result["PresignedURL"]) + if not is_success: + delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + raise HTTPException(status_code=500, detail=f"OCR job upload failed: {msg}") return file_result + except Exception as exc: delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure logger.error(f"Error uploading file {file_name}: {exc}") @@ -78,7 +88,7 @@ def update_file_job_status_endpoint( Update the job status for a file based on OCR job updates. """ - updated_file = update_file_job_status(session=session, file_id=file_id, job_status=job_status) + updated_file = update_file_job_info(session=session, file_id=file_id, job_status=job_status) if not updated_file: raise HTTPException(status_code=404, detail="File not found") return {"message": "Job status updated", "file_id": str(updated_file.id), "job_status": updated_file.job_status} @@ -92,8 +102,9 @@ def get_file_status(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): if not file: raise HTTPException(status_code=404, detail="File not found") - ocr_result = get_update_file_ocr(file=file, session=session, user=user) # Poll OCR API for latest status + ocr_result = update_ocr_job_status(file=file, session=session, user=user) # Poll OCR API for latest status file.job_status = ocr_result.data.state # Update file status based on OCR job state + return file @router.get('/', response_model=FilesPublic) @@ -133,7 +144,7 @@ def download_table_excel_file(file_id: uuid.UUID, session: SessionDep, user: Cur ) @router.get("jobs/{job_id}/status") -def get_job_status_endpoint(job_id: str, session: SessionDep): +def get_job_status_endpoint(job_id: str): """ Get the status of an OCR job by job ID. """ @@ -144,4 +155,33 @@ def get_job_status_endpoint(job_id: str, session: SessionDep): ocr_status = get_job_status(job_id=job_id) return {"job_id": job_id, "status": ocr_status} except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) \ No newline at end of file + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/batch/status", response_model=FilesPublic) +def get_files_batch_status( + body: FilesStatusRequest, + session: SessionDep, + user: CurrentUser, +): + """ + Accept a list of file IDs, refresh each file's OCR job status, + and return the updated list of files. + """ + files: list[File] = [] + for file_id in body.file_ids: + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail=f"File {file_id} not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail=f"Not authorized to access file {file_id}") + + try: + ocr_result = update_ocr_job_status(file=file, session=session, user=user) + file.job_status = ocr_result.data.state + except Exception as exc: + logger.error(f"Error refreshing OCR status for file {file_id}: {exc}") + + files.append(file) + + return FilesPublic(data=[FilePublic.model_validate(f) for f in files], count=len(files)) diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py index 370f6e9dc5..aa6ca460a6 100644 --- a/backend/app/files/schemas.py +++ b/backend/app/files/schemas.py @@ -3,6 +3,7 @@ from sqlmodel import Field, SQLModel + class FileBase(SQLModel): filename: str = Field(min_length=1, max_length=255) content_type: str = Field(min_length=1, max_length=255) @@ -20,4 +21,8 @@ class FilePublic(FileBase): class FilesPublic(SQLModel): data: list[FilePublic] - count: int \ No newline at end of file + count: int + + +class FilesStatusRequest(SQLModel): + file_ids: list[uuid.UUID] diff --git a/backend/app/files/service.py b/backend/app/files/service.py index a7ce8c6b52..f07cd10471 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -1,17 +1,16 @@ import io -from app.files.utils import get_df_from_result_json -from app.aws.config import aws_settings -from app.aws.client import generate_presigned_put_url import uuid from urllib.parse import quote -from app.files.dependencies import CurrentUser, SessionDep +import pandas as pd from sqlmodel import Session +from app.aws.client import generate_presigned_put_url +from app.aws.config import aws_settings +from app.files.dependencies import CurrentUser from app.files.models import File from app.files.schemas import FileCreate - -import pandas as pd # type: ignore[import] +from app.files.utils import get_df_from_result_json def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: @@ -27,7 +26,7 @@ def delete_file(*, session: Session, file_id: uuid.UUID) -> None: session.delete(db_file) session.commit() -def update_file_job_status( +def update_file_job_info( session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None ) -> File | None: db_file: File | None = session.get(File, file_id) diff --git a/backend/app/models.py b/backend/app/models.py index b5856cf5a8..0751ec5800 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,6 +4,7 @@ - app.users.models / app.users.schemas - app.items.models / app.items.schemas - app.files.models / app.files.schemas + - app.storages.models / app.storages.schemas - app.auth.schemas """ from sqlmodel import SQLModel # noqa: F401 @@ -24,6 +25,11 @@ ItemsPublic, ItemUpdate, ) +from app.storages.models import UserStorageStat # noqa: F401 +from app.storages.schemas import ( # noqa: F401 + UserStorageStatPublic, + UserStorageStatUpdate, +) from app.users.models import User # noqa: F401 from app.users.schemas import ( # noqa: F401 Message, diff --git a/backend/app/ocrs/schemas.py b/backend/app/ocrs/schemas.py index 197a6a7734..3f514b22bc 100644 --- a/backend/app/ocrs/schemas.py +++ b/backend/app/ocrs/schemas.py @@ -7,7 +7,7 @@ class ExtractProgress(BaseModel): totalPages: int | None = None - extractedPages: int | None = None + extractedPages: int startTime: str | None = None endTime: str | None = None diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index e7125d2964..1e8fb51d24 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -1,23 +1,27 @@ -from app.utils import get_bytes_from_file_url -from app.aws.client import upload_file_to_r2 +from sqlalchemy.testing.util import total_size +from app.storages.service import update_storage_stat, increment_storage_stat +from app.storages.schemas import UserStorageStatUpdate import logging + import requests from sqlmodel import Session + +from app.aws.client import upload_file_to_r2 from app.core.config import settings from app.files.models import File -from app.files.service import update_file_job_status +from app.files.service import update_file_job_info from app.ocrs.constants import OcrJobStatus -from app.ocrs.dependencies import SessionDep, CurrentUser -from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse, OcrJobResponse +from app.ocrs.dependencies import CurrentUser, SessionDep +from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse +from app.utils import get_bytes_from_file_url logger = logging.getLogger(__name__) -def upload_ocr_job(session: Session, file: File, file_url: str) -> File: +def upload_ocr_job(session: Session, file: File, file_url: str) -> tuple[bool, str | None]: """ Submit an OCR job for the given file URL and update job_id / job_status on the File record. Only posts the job — polling is handled separately. """ - logger.info(f"Starting OCR job submission for file {file.id} with URL {file_url}") headers = { "Authorization": f"bearer {settings.OCR_API_TOKEN}", @@ -35,49 +39,62 @@ def upload_ocr_job(session: Session, file: File, file_url: str) -> File: "model": settings.OCR_MODEL, "optionalPayload": optional_payload, } - logger.info(f"Submitting OCR job for file {file.id} with payload: {payload}") raw = requests.post(str(settings.OCR_JOB_URL), json=payload, headers=headers) - logger.info(f"OCR job submission response for file {file.id}: {raw.status_code} - {raw.text}") raw.raise_for_status() - logger.info(f"Submitted OCR job for file {file.id}, response: {raw.json()}") submit_response = OcrSubmitResponse.model_validate(raw.json()) + is_success = submit_response.code == 0 - update_file_job_status(session, file.id, job_status=OcrJobStatus.RUNNING, job_id=submit_response.data.jobId) - logger.info(f"Updated file {file.id} job status to {OcrJobStatus.RUNNING} with job ID {submit_response.data.jobId}") - return file + update_file_job_info( + session, + file.id, + job_status=OcrJobStatus.RUNNING if is_success else OcrJobStatus.FAILED, + job_id=submit_response.data.jobId, + err_msg=None if is_success else submit_response.msg + ) -def get_update_file_ocr(file: File, session: SessionDep, user: CurrentUser) -> OcrJobResponse: + return (is_success, submit_response.data.jobId if is_success else None) + +def update_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> OcrJobResponse: """ Poll the OCR API for job results. Returns a typed OcrJobResponse. """ + if file.job_status == OcrJobStatus.DONE or file.job_status == OcrJobStatus.FAILED: + logger.info("File %s already has job status %s, skipping OCR job status update", file.id, file.job_status) + return OcrJobResponse(code=0, msg="Job already completed", data=None) # ty:ignore[call-arg] # ty:ignore[invalid-argument-type] + headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} raw = requests.get(f"{settings.OCR_JOB_URL}/{file.job_id}", headers=headers) assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" result: OcrJobResponse = OcrJobResponse.model_validate(raw.json()) + + if not result.code == 0: + logger.error("Error fetching OCR job status for job_id %s: %s", file.job_id, result.msg) + update_file_job_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.msg) + return result + state = result.data.state if state != OcrJobStatus.PENDING: - print(f"OCR job {file.job_id} status: {state}") - update_file_job_status(session, file_id=file.id, job_status=state) # Update all files with this job_id + update_file_job_info(session, file_id=file.id, job_status=state) # Update all files with this job_id if state == OcrJobStatus.DONE: - progress = result.data.extractProgress - logger.info( - "Job %s completed — pages: %s, start: %s, end: %s", - file.job_id, - progress.extractedPages if progress else None, - progress.startTime if progress else None, - progress.endTime if progress else None, - ) key = f"{user.email}/{file.id}/result.json" (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) if json_url: upload_file_to_r2(key=key, data=get_bytes_from_file_url(json_url), content_type="application/json") - if md_url: - upload_file_to_r2(key=key.replace(".json", ".md"), data=get_bytes_from_file_url(md_url), content_type="text/markdown") + if result.data.extractProgress: + logger.info("OCR job %s progress: %s", file.job_id, result.data.extractProgress) + increment_storage_stat( + session=session, + user_id=user.id, + size_delta=file.size, + total_pages_delta=result.data.extractProgress.extractedPages, + ) # Increment extracted pages in storage stat + logger.info("OCR job %s completed successfully. Result URLs - JSON: %s, Markdown: %s", file.job_id, json_url, md_url) + elif state == OcrJobStatus.FAILED: logger.error("OCR job %s failed: %s", file.job_id, result.data.errorMsg) - update_file_job_status(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) + update_file_job_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) return result diff --git a/backend/app/alembic/versions/.keep b/backend/app/storages/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from backend/app/alembic/versions/.keep rename to backend/app/storages/__init__.py diff --git a/backend/app/storages/dependencies.py b/backend/app/storages/dependencies.py new file mode 100644 index 0000000000..96419242cb --- /dev/null +++ b/backend/app/storages/dependencies.py @@ -0,0 +1,6 @@ +from app.auth.dependencies import ( # noqa: F401 + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) diff --git a/backend/app/storages/exceptions.py b/backend/app/storages/exceptions.py new file mode 100644 index 0000000000..719837ee62 --- /dev/null +++ b/backend/app/storages/exceptions.py @@ -0,0 +1,6 @@ +from fastapi import HTTPException, status + +StorageStatNotFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Storage stat not found", +) diff --git a/backend/app/storages/models.py b/backend/app/storages/models.py new file mode 100644 index 0000000000..08aa6331c0 --- /dev/null +++ b/backend/app/storages/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime +from sqlmodel import Field, SQLModel + +from app.utils import get_datetime_utc + + +class UserStorageStat(SQLModel, table=True): + """Tracks per-user file usage statistics.""" + + __tablename__ = "user_storage_stats" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="users.id", + nullable=False, + ondelete="CASCADE", + unique=True, + index=True, + ) + file_count: int = Field(default=0, ge=0) + total_size: int = Field( + default=0, ge=0, description="Total size of all files in bytes" + ) + total_cost: float = Field( + default=0.0, ge=0.0, description="Accumulated cost in USD" + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + total_transactions: int | None = Field(default=0, ge=0, description="Total number of transactions") + total_pages: int = Field(default=0, ge=0, description="Total number of pages processed") \ No newline at end of file diff --git a/backend/app/storages/router.py b/backend/app/storages/router.py new file mode 100644 index 0000000000..fda767a7dc --- /dev/null +++ b/backend/app/storages/router.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter + +from app.storages.dependencies import CurrentUser, SessionDep +from app.storages.exceptions import StorageStatNotFoundException +from app.storages.schemas import UserStorageStatPublic +from app.storages.service import get_storage_stat + +router = APIRouter(prefix="/storages", tags=["storages"]) + + +@router.get("/me", response_model=UserStorageStatPublic) +def get_my_storage_stat(session: SessionDep, current_user: CurrentUser): + """Return the storage statistics for the current user.""" + stat = get_storage_stat(session=session, user_id=current_user.id) + if not stat: + raise StorageStatNotFoundException + return stat diff --git a/backend/app/storages/schemas.py b/backend/app/storages/schemas.py new file mode 100644 index 0000000000..ff2f7c9616 --- /dev/null +++ b/backend/app/storages/schemas.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from sqlmodel import SQLModel + + +class UserStorageStatPublic(SQLModel): + id: uuid.UUID + user_id: uuid.UUID + file_count: int + total_size: int + total_cost: float + updated_at: datetime + total_transactions: int + total_pages: int | None = None + +class UserStorageStatUpdate(SQLModel): + file_count: int | None = None + total_size: int | None = None + total_cost: float | None = None + total_transactions: int | None = None + total_pages: int | None = None diff --git a/backend/app/storages/service.py b/backend/app/storages/service.py new file mode 100644 index 0000000000..de08fc41ef --- /dev/null +++ b/backend/app/storages/service.py @@ -0,0 +1,69 @@ +import uuid + +from sqlmodel import Session, select + +from app.storages.models import UserStorageStat +from app.storages.schemas import UserStorageStatUpdate +from app.utils import get_datetime_utc + + +def get_storage_stat(*, session: Session, user_id: uuid.UUID) -> UserStorageStat | None: + return session.exec( + select(UserStorageStat).where(UserStorageStat.user_id == user_id) + ).first() + + +def get_or_create_storage_stat( + *, session: Session, user_id: uuid.UUID +) -> UserStorageStat: + stat = get_storage_stat(session=session, user_id=user_id) + if not stat: + stat = UserStorageStat(user_id=user_id) + session.add(stat) + session.commit() + session.refresh(stat) + return stat + + +def update_storage_stat( + *, session: Session, user_id: uuid.UUID, stat_in: UserStorageStatUpdate +) -> UserStorageStat: + stat = get_or_create_storage_stat(session=session, user_id=user_id) + update_data = stat_in.model_dump(exclude_unset=True) + update_data.setdefault("file_count", stat.file_count + 1) + stat.sqlmodel_update(update_data) + stat.updated_at = get_datetime_utc() + session.add(stat) + session.commit() + session.refresh(stat) + return stat + + +def increment_storage_stat( + *, session: Session, user_id: uuid.UUID, size_delta: int = 0, cost_delta: float = 0.0, + total_pages_delta: int = 0 +) -> UserStorageStat: + stat = get_or_create_storage_stat(session=session, user_id=user_id) + stat.file_count += 1 + stat.total_size += size_delta + stat.total_cost += cost_delta + stat.total_pages += total_pages_delta + stat.updated_at = get_datetime_utc() + session.add(stat) + session.commit() + session.refresh(stat) + return stat + + +def decrement_storage_stat( + *, session: Session, user_id: uuid.UUID, size_delta: int = 0, cost_delta: float = 0.0 +) -> UserStorageStat: + stat = get_or_create_storage_stat(session=session, user_id=user_id) + stat.file_count = max(0, stat.file_count - 1) + stat.total_size = max(0, stat.total_size - size_delta) + stat.total_cost = max(0.0, stat.total_cost - cost_delta) + stat.updated_at = get_datetime_utc() + session.add(stat) + session.commit() + session.refresh(stat) + return stat diff --git a/backend/app/storages/utils.py b/backend/app/storages/utils.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bun.lock b/bun.lock index 34d6b22a9b..4a52f5d93d 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dayjs": "^1.11.20", "form-data": "4.0.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", @@ -498,6 +499,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], diff --git a/frontend/public/assets/images/logo_dark.png b/frontend/public/assets/images/logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5a39677071568eb8330f34ba07c3a532df10da78 GIT binary patch literal 224391 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�OV_c(~1LAs7fC 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 literal 0 HcmV?d00001 diff --git a/frontend/public/assets/images/logo_light.png b/frontend/public/assets/images/logo_light.png new file mode 100644 index 0000000000000000000000000000000000000000..4a50c751aacf8f0f118da8740734b9c9fa97f4a1 GIT binary patch literal 253851 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

&ypILpdZI6}-}8j7Tm9R%`*hw;EA0L6 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

kSE>RF{VXfdX=-DzSS#c$IT7|HY zn5)FupI3#KYH5gaRC1G5RZ3!s-(ReuEDTZVtSMBHs*U2AiC8OUrs)PNP7F=a;5qdyC?@tvVwx_V0klz&=MUnB!h}U{lrIBIGZ3^R~vke-6}Pkh6(f@ zg6TACAz_Xgo<<1#gqLe=N1Ah#395$>na|Ho`yW6{jo*A#yf0d3Pp+<#X>SfEL6}ep z(X;2M6ng)$$@!*kRJyPWs-CEa$c^8u6#KUgh+$Gc2Gxmo5>5vGtHdrdcEAibLw5Vk z{|*Z>>HleT!H~#mN?gUg@tTRn?B9`$&O-fw?>C54vB8%Ou07B#Bf4^sn5&6JrPB4k zAmay9_;Ct!Kx8fbXO!@b4PiWb(3m{&M|n2exi(f)JN4=E$NYU&%NCDoPZ2}=>toH= zO_B^cy`Np(H#oLDKmrM#x42fQ`t{cn2%CE78~CFOaXO7gp)P=Yu2<|&k{;*Sk9P2o zdU8%Vc|s?W5@$|WT{Fy?NntN>s=2VC4=gvh+}?zdM9zc^2_BA~F2l-NN<{rB`rQ5! z55CzO$9q=!1@sy7s!PNBYZD*8XWr*j_~eX2b*ToZ*@pdM!*rzqZlzJY4q%N$E|+iz z+|c>CQ#8wcu4&Enz*#ZLjZY_$ONm#SvxB1V7J&N}8};xov5l+$`cLLkPvEo6!0{gA z_8xruIV(-?C*Z}8BuGRc$?H3=c;#Mhn!|57RNbeqW%|9f=H-*;vOTV6wA<}A;_fO5 zVb~cSKME@c4d;?;95iTaW!+rI?dpjQ$Nlw;E$epj@4v4}>cn@~^;)-g`t>&Vndhe` znQsR@+PJ!YlDAYVDQ&5bHOZ&>ZBPbtGBBzD7vSKCRv|;ARE)WBa4u*aIAGzYKf-^6 z!~-FTu_9Wbu}IOR7Whz!K{(7yohxG_rQ}aG(>4^IA z`iGj1OzG7-t~b|OU1IxLy-ItYotDiRc%H$=eSuYu`FIBC~ zFI%5(a{+0k{Pk*6-5ZvwaBpmp=239{+3c4zH6%TrTq_2bysTwguh7D;$298+3ep9$(%N2OHYQ5Hl?mlLaX zGigPvmb1w1#L2kt`#*oGQP20#V&pvzwBC)xwaK1~pT0JLG4KH~cSYF;M?JIm8k@<8 zWcUw;v+veT_SuC)MS~8e_nF-QL!ifIW|%=)`XS;C_tsW{$A!NZbGp+T5FH-J_u z)alC|CZcjz@tn8ojep@Aw967?b4*{ip7R5Cx;=lN8Y`^*k<;NF4o#+^J2|g;!2CW0KCD1<_tmAvR9gf~ zz+P?D#!MaqW`Df4(&phxuRn&>DlBGo6v%7Hi-tljsxD>sYVq>DMr+7MRAc}dIf`CsQKW(pYh{+wT9!3xa2((<-4G(sY~;_ zEV^ss@jX$nd9htt0e~(&f6ux->XQJhdS?4RvUVda$73b7ta%7t^RL^VLAxEVcrI=UI)$&^KVTT2`OGKWw>fdr@;Fdx1b!FhxeJ!I z=sX}=mp#MZEqMGq!@IF`*zJbbTybih9=ZLDR;_$4$`>_X7NBXpyg>2wy2c1}I?$$V z*2t{gFH*L;29)Dm9enJyk^WKliD-2xLn8~{*c4CFPN*UlMxgLG0q^}y&E*V_0BVzv zx9 zt?e0>?A8bs1RA9A0T)s}7GET8(>@5Je9s#=*WG^I(|J9k1Uh%5>Xn+mv)FBtx_I1; z%k(ia8!O4~mzJoJ3kcZSag5vBONxT_7ng`^>1-)ADwL^B2}cXPK{10^$`Tw5k8)8e zJ8OxNHzA#o`NtMSsPc8oJ=#iwogmRYZ}=xYqB{SG1i=wtR$E_Loi=)mzc`*)+CML) z!d7Y3I#Ch@|7~&I_OC_K$KaQ!x8ZL%8fRz^?|gRbSrE)29j1o~9fcbFUEDqU zr+3AC4C(#3YXCUGYGXxGUGR?-mzz^XIAMc=B8Nl*=!Qcfl?Yd)`p|?eR`FhF73kS> zwOICh*}L2X?1bCz@e8S!xf=b59EDCCk>wtcsBOQ%?yGotf*e!&=nkYsXGeIb(K0#G za0l)NaQNq)>;xAELkrY2T1a4~ibFPo4oeb+M~oLaj_p{SFK?EkZI0>XYQ0M~yTT?X z5p&&XN*o^3z&I>Ea+gNe{1V;zXk#Rc$w3b!36{7c@4acfKZ5&HAc$9(P@-V7WGKVL z@cW%&&{W0l)Rd1oH7teUK&1En{%Yno{#1iyQ2yICDDY4K-a9pwV(b`4(wEV#6Ekm| ze{ztKeWyXfW~}8@(EQ=|Y--^RIN0@bGc$rrzTcnUXq_85Cs0)=RQyrl_ww?u3;)|! zAOHjWX^v&LX8V5<40;73d(kumk0xJx(1X%&{k-|zE;K#wSIw90G6j0>2c5ScJ2X09 zNU{hrT5h}8pOULD;#p+LVl@uY!^SSF22NvqeHEKOF&Jw|^ zbu3%fBJ;R@}&*Xun|bzsIr$t_57Gy&pD$1`1Txd}OoM zgVkYdQ_8S7aJ?-gZOb^8-T`&LH~$#zhsF$PBR_4fy9F28&zu7wOs0=)Jw8s=-e!y^ zOt*}!KfcH+9JcpTTFt+h+x7z}4wl|?Z`O|lf+SL_p2E_qM~bXD#_HudOkbD2YJH#M zZO&`fue^}%ET(M8;y02BKt!kmqg_;cA5Y1qmaEh_hP8?0RrcHY|mp|ky zb+zOO3QM6l-3CeNNU=-}c(P{&=*0T@SbhApRtk zge+RCzsGj*bm`j@N`@|3WyRXrFEbUb3lSd|0orFb=TCF=^epV{?^B(kn&XSp{;Cbb zrnjOH8i`XH^qrt7Uii*^BMrVtz{%DOIa8xouGLSwx~_f&DiZ@aD^T(rd}>IB8MStm<=6L&0eFkGU!} zT2tt(EmWXtH3mh%JIh7kojuq6VEJH-fI+dU(faiRirlW4VltgS3MQ}hCT`IP%1E%JIZEob~e?`du54rJe3DydE zzs)9;b!8LAW_`E$H;c`H_JM+pM4d+nYFzb0YjV^VTn8af7;aOjq>;L?fEheGbTtPF z*l?CCh;E~B>jB3ao7*4rIRD#fbzPZ{+cw{hbCo!#=E|q>Rw$3L7W$MC_D}s5n|Jcm zj|a4;!{r>>X+9U$;TBPonD(cqC*$>Iy=K!N9-+mv!+xzbXxgXVRj0>xbCsdP{dsNp zva8wb(@v|+(fy)gvHuSrXuGFQ4R5m>=#|F-RhQ)#%F{{x<6Ie>c9l|6->%Ru6mjP$ zA5D2a?GR>oh=VBu{gJf3p>Qi?VuvSbLn-G*0y}0II=0wDp+$Ov z)L~3NS==aIc*qRzKeXg2L??zIGUpKH`UZs;k%ir9yK#M9}|X9T9}epn|?OK zkJQ(0aG9zY(P*U2A{J7Z@+ZZK5dKg)-U+=oAkg~3t0-0v@NBq$RMqbLNp7>%iq+wE zDF#6|eDCsIJAiNt`ph$fu;$18l*bK@G8}!InhHILc%KfSYNIpF44K4%e7W53xU8M9 z7kZ3mc$bNNfk6Mf(7$D>nz@I^@Vlbj37$=DNmG1)%YtK#mtTH`ju>QB5P$Lmf)tnJ zvDm=mYw*88NYBNR2!DL&hN6%?W8n|-bjDW#BSaVMgG?|BYmiGqMsdS$X^`f_?Bz=9 zL?%qho(@LHb_0Pa46PI^)}&c5-!L!ybZuLy7P|PZ4|%hJBviiO5_otV#ds76;h*6KFis@al6u*^fOH-rzM$R9jYNpKNm_RBd zR$#*7`CzdVWl`CARHxVxnH?luC_&8}=)K)IOE498G27CiTsf0A zUqt%37#eI0Jv2+@Gw)(aPdtCGf0Q? zra~RyQ{xfgx&x?zJasmcBOc+&c;X4K-uK+@2Orwk#RR*Qjq)j-RkF=5rJ~m~YIJSr zCtAs9>HHB;1ZPQa{H6x+MnRN6d|$Rr!pdAU^}{O zvr^p!fH1V9jEEv~f+CRPlSV2^M`PBkST*e8zkBX-W?-#39=+$S7z#zz-2~*$NxyQT zl4>j{yH>x!{4NK!IXvSpX2*ypNWXJ(`U!q(4#z16G7Z)s#Gp!(q8>Y#(85B?b!pM~ zTi4J9x2d;TV67^FySzz<_ahFYbLJV))?59Po24eSw|Yr1A@UOyk-&bRgA zwm)~hzRwNphc?|e_V17X%~Rxg2rpG?Rq43frF91g{kx}x7Ar3?($g#YNe*!2x9kVY z;x|zcbWnr3RDzag{IW$ zgI2F~nxZVsrW;GNF_i%UrwIZf6eL9gGYSWVWgIx*WpLf*KK@?xiC)^$d+)KfcK2iV zF<7qpn>1a1Ho@h(&1)v%Y0SgvgFU{@UZ!oEuHT_-s)!KjqF1Co+DR2JJe1#1(Q2WU zj|spUM@w!hS3Ik*DY+{+&jm843&81@w(resvaa>r zd|PSul#s+bn&=KfSYe3ef0M*@-`I7W0e){pr%$i>WJ~__iw0^D+YBuoANCU2_BS4B zW2;K2-&KqI@7?)tCJ2n&5Ocnhpw3g69nEL{kMd$ z5`di-++HY#(U63~08DH)LBraNC1j^MZTpb!w5lnBb{jzPUbo|zwhK({vSH5aG%CSs z_5P`@?q%RE>od>yjmG=*x(9#z;|O7|ABMi=@tnhPYLe)pZk-qpm2Q0Ad|I~Z3238Z zZn^2g_3n-aJo$Qt9J-1V`JW)TsGI?3D|v81L@2R_#UhhzT_bI0e_5|ZFDjAM^2|b5 zzG<#7H0nGMw5m-c-}v{c5)b{uPOZN@MsYYKW>y@Ct|BuPY4gweZ)`GC5DV=zxl=bd z0C}Ghn#y!;kg+}YkeRX3j5GnY@iv0V4FbX)-|Hj*Ll09aM;wZl);}FcSy#z-8R(zd z(Z77ZbBTO{0AH{-q?ZPX-meSfcaV3alm9sTj)4@V^hEvCGFP%B-`Bei6#Ol~b*(YY zb<H4~8{Uj^FZ;A}#4pXUp)w-V+e%{SICg6>;d8xxy?;yo}XBpRm(fN+`sw3d1 zL)&xJ)qVM=<=KtaFUq##_F|#yE~fQ*Zh5!g*#NoYx&e`$DdWVSYF(U4bE61V`Q{x( zNpr5XxMUU;f2PC+wH&+>Pon$IX)PA8|G@ESTDkJk9u z=lW@I-ju_=0s4$}0Xw`Ida0T08n)SH_=d0)PHw+(w+|EF#@WOqJ#%FAhS5xxpbm>< z-sWx8MVCbI>_^)yOztX(x5ve!z8^VdT(j{cC+& z->Tr zvaS!_*Grhd+NS%q9NI8Dcl)N=^vcZ);?ZPoh%(r;0Ve_sI(={9P?XQ?o|tBLDm4>90GBcg(!y&h31>}XPg1-JX0D-z)mP7C` zPO(H3VUb1h=&$$a(XbPl2qJ#1Vw-_!av@#4A?0^%KbER)X4eTMC@b93E|r2C6F(F7 zPiWB4PdRg)u}c_YEf$OUkUrpI>?g;J6wmQsm-k`@)HsgMZGTdWg>qf3M#`|v6s;;i zRmLo^Qu5DV1Sa+)k6n$Lrd^VrXv=&3-!)$SrL9-X9Dp@*b_fCh?3$lrDd*1|5g;8OStYU~}wBMXK>SOX9QbDxvH5W#hZOL-v!T+iZ8d zIGL{(2>Gp7E4%kumY23tyzrq0lgS?-Lm+tdoAeKLmmT4|*z8}OR4BYh38jrPfQxa# zptH~3^8RD!{O6<*L9)Q_{|+;G9(p2~i4zn6`R+kdoGaa6@|ZGBgG1kA{yuN{`egXt z%gB43OQmBe-StPI6(0$6*uR6n`WisIGvYa|nZ|y(Wy4r)mOFO1k3`sAtKs_z`yU{g zn^#!fMp*OF$5vj&X-f&!8GOROq%-J>49orS-SC(q8y6o*P76JYg;X79`B!N&g$S1L zSPuFJcHLTJDn`l{&7|}8thX!eWncdtcb)5th37hGdPsjBtLuDK^!I;r0ojG?pfIl< zb|W5D7t7jS4ZM0opRkjd8L?RN;*7Qy!SfKmIA^d$7wv>r>B-5R&KCJX&w{ka1cZ`} zd@M}Doy}!A@ZcRROfpXu{i98~svk_E+)86eVmT*sKMzI*Q3yOUFWW4H9>ggeuDi~? zH_w{*tAoO~f;GA>jg^*B&^PJpwcTxKhbcN{@vhspD$l(OV^V=rrSa*v58WADZg!F| zua}%?jg6f8hb1J8?~ihR)F0%PdOh$z`maMhn(M=+WxC@O4TGpChAt2c{RbVMZ;hT< zf;@Nrp68;x+wAL4l&Q}vg_j&>zfpUibHLNH!8>{PLyG+_jlnm1&u7*3&d)c!zF+qS zB~gCvQm#K+{+&Nr_5lR6jV)O9b+bRDLyRL1F7sCzof|UffAU3+t|PK~{3K z-7SOQFY0GjzA!i~SnaqC-r9+Mct=y89M}-DQ0bWQ8?b zpei>MNXipK$q?Zwb(~qdA}dQgYA}=p=^1S_l~OVfwQ(J{$IVWkL%gTW>u(hPhgpR` z2tGH)A0zgD6xCgC&nxWBF~4Ya21VevvnHMlknb@jAKcWKmLpSQDiZ&dNvhrXr?$Q3 zfc(@W2M?lFF8D{+?*rL#rYx;Nr=sGEn6LsQV*wU)Kxa;8RHliCAwrP0!Ya;}Qx2SD z`tox&Rs?|-0kX*2utfFbP~w6dw=v}%_*F?u_WkH$fKlQZ>1qS}rK?wYsFmUn7elG;{ePri;K+nLR zkPrVkX#D;_CXamPNvRsh3V{5_=}ixQKm&2+vBK$p zP{=>yJuKOEkZrVqJjw}ZAT8dvc)vGbZog!--t5O68OLS*I|YrP6CNw&>!tXog0-9MN_VFuKvaSsZY5fd z&q{Te!vQAzb}z-=ch&%Yb^>23D);%c*YfhpO$ZGgR+*KP-0v5sRho>}sasbH1Ck?i z@@R)WZ$N=QWHdpOFuOMc#c@+S~{ zd11bFU~c1P0^ZB$xm+x^ULtc1-DW!s=6Y;IEmmoo_76S$H6DkuU2h;)Jk0Z0vm?&r z9LRJz9O{IOHhtPmcRsm{u6G?R(rUM?U^eKSIbzNj{^@dL{P;&Z8M=wMFDQYtol8^_q7g;f@&??$hh>et4*L-+3i&bE)gt*T8ioE8vyq?rYBd zK~qefi@<9~Y-FFE1ap5H$R(dPPI~Dqf<%g6TptSk9r67c`#DHG@Vl)`!embCfesVK zMN$JoPac{v1I~yf%M-PRxdiec!Hi}|+pPdQd@fp=uyYpQ2(hU$a6^$?9ZPYl#jfzn zn{qh78-6e8Kxgj=aeGoY6d(Ef!)f5NZ8rF10^v) z0!k*GA0FmBP7x9sO7b4fE-H*QdgBI4xjqOphS70SqzoKkjVvcDMnc4^zecWm)pE8i zvrf}}{|sfylsEuM{{K}q)TlG2Z(mVcU333YI-VesLefUbd}*HrF6o-z9lSbzM}E4G z+!qyB!2LcK|4tk>g|XZc=sJ)2D>m-f+Lt-WdcSqSA?Q5+l$J?p^UGD62)IINOg^B2 zbW3io0%kJpd35?#*3tQsM8K2kw(61H`bY>8=lRck%a+tPy6G_5Xhveh2JjlLY?)8l zPdLiyz8Rhiy=`|k-+AJ_Ca%t0-OG2Jcs*ABWuT4&RO)?1IiFXwFtBiV#%SCKHlX!@ zNZ{4Lqi#jf|33Pnth;T&OviN%tC~|7z{uB5cj26eK)F`K zHQ?EYPTZeN?95lGK>g|Xaoc+E7n829WpFC@_e#BAjPAps^I6)(MynN@MzsOH%~H+5 zE8|(c-PzxjF4GPVYRTD2Aeq5Kx^emD(DhE8A~9}U+ug61POnC8i&-!xoz@vF2A#l7 zEXKZ~Fxfh@QITd!a{_p5#zQj9M);#i^+I;8$xM!Zi;0{#E4I?%of#HrXsGHn=UB#tD7gePn-h~Y+;><2)+=;Lk_%mug?AwJ(B*%3Pr6PDJj+>Z8 ztYm7J8b6g^o{C-A?@n)#Q z%M_S&#4vre{~4UqU;_O}7Rol{P1kLJt~ZKpifYTbsnDgRe9_yOzRE=G$d`WL~7r)ZNnzSG^BCyPC8JW zYs7>aEdgGMqQ>HnGpwWII$MDGh4hDGS~Bfz;s|o+9lWX#F!1tYH*pDkO_&>NGDxL} zcIsCINbKXN`lVJ?=x~H;Qo=KNVzEW_gO_1OR6)Hw$0I>xTI>6CKuh?{B$>LB9$GA13EviPnxnnme}#rCrjjdYpmwbB*wbV|hDtI^aO} zCcLF{d;4~&@tpU_pBR6+y4S#mA+|q`JM`_c+i+sp;IfIA2u@^5-`hdum#m42B{+)2 zi3trJizF&s6Lb|X?xE>nrW_LsEIu=5HBh^tw#KD7(fcA zpnMWJYqYGhRx}3I>hXEPS!u6gcK3NC4;L2_mF09tA=T z;&DOGD_#}?Mr&c(?*`^#9EJ^{u_XCbEMX<7s>UaYc>dQBWJyqAhNAO;{~3k+AwIXo zdp@@-E^RLVW5PhoKwK)mr-ac{r_8I-y)%(a0pD+f$^;$~E+1NI1x^co=Lei!X!->} z*_xrPFO`gPEEhxbAFW!gr%mtdGjoH2n@qN8uvRImxBy*bBo^N-oB76Ydxiu2*}}29 zhYbD4s){=7O|K)#>4~}!eh3w4IH6V+^v$Y4t)_;b;ZylcCd3b_CQ zou_)Ahy9AXAR7-w(`v8UoRYfg233ev8NTa}c8{e`{sPF;`Z46lTUDRK%$?NnS9;6Y z-RG9)ZqNODJ->;Kq4Vy)+Y0tXZwryVQHWf}Dn1KT6(2<7y2yZWDx1X+AJ8ek z+o+23=~IEdaDNAKlM9vjpMNKe+ElXG#FS0qRN=mmL#A}Xut)+ zg*0h}MN}oAh#6{BUghLTsm=f;>4=J!rC+}^qrCeMB@-okx_5SV4zu*g23&a<mQgC__ls_ZIn#KFw?s)Vo?pyj z^jh(XWbq0h$w8J#8YIFbGPV>1Je>4QW3(Xzn#c*1Qh_IuQl2n}4FeGKd#UACsf9FT zeFf^FSTG09u$QSCsBgOKc;&*fw$G}e&R1jaEs%fe|4frccyWCSa0Xu zG2rB)uC>2)J*st|aNLloc6rQ|SQwIMWfJ9m44r7}6LDFo#VD#L5`(E!F_f zFPlh3>N$y#DiAFQ^ns{KlUI0Lb2(&s=c7ePkYUyr`#SwUBR2pG^ev#Yt+w?)#x4=d zx0SM7oG`cx&)lh$Dd0HCv8;Bm{K?$)c&Hl3`&h9oXWQNw@>s+Km9Ehk&)*yHFdWbz z+vWR1fu=>sgcrbxr$;&5PCWIZ-oU{C2v~ zhpcpNU1RZ426FZBVtpT%*%SBIWX3ph_uV3Ao9V*gm5c4#RfeR-8mKJZ{v zdj+P`{^Ixlt%%nZ>d~YZ+S*7+s$*TB~_43$(1BM$y@EGhys-g4W@D23tP|a z>BFQE#2DY9GIfgsVSkTMcC`XN52{eBkLR&%v4$!RpKi@~>kRh%C^?3sQEJ5+B7NO@vN; z9K!7x?jwcJCZk1R1aqV^baX$>qa`1ut9_}pzPug;{cLpR+v6T!ixY7>-HHTS6qPm9 ztuJDmzES8IVrO2>Y-uo8p&vx2kcv{kh))#C0%%-OXq2(F&XRX=%Q*@o^@6Mp$(GeZ zic+!ID?&MJy-rSi1PP4;HF zXvvte#~Yji0Q#Etxcrw5&70HZZhqih?p42ecL&<$&5z5*CnBc-<%sJYQ6zxZ9hMer zqXGXe@RFjPzm54$S*^FPLKHfEK@6aOnE?jW$wMEuuqp}(5w1uO zIbVndsG|rw9t5dggM0}5hf`=vq*)4EHGhpg$#^P*<@7dWM8I;K$f!++ZxA29FR@j_pq?*W2`ez6}g zd6IWAMu!vrpJSHbw-$3_Y*{O_p`9Kt~>E*U(<&OW}L{dN6&&%E{oLMR*M z1Ypm2RMa7p7t_{8buTK{>VL<5-&P4iob<&K{Fqn<(bYU#d=lN2B#gw78%Z`rT6}_` zD5)-F=d1#yOg5K}5j|6Kb}rk|cC|b?Y|%r`=D>fldzT?-v<;d9_ckHb|9K&lT4b$R zNoQ5-J-lr?bUyezwJhL3uu6_a?A?ClvT*Xl=kt5r*VVURQ>1J@3iaXPxwRH}$JB8; zSGC90{T^m`-$*<#c=Rz+3w(cX{Twq~cS|=!3wZy4eE%5ij;ms7W3g{L|&<~38eDfi24d}o! zF2^oKDMINx7u3yAXxhQWHpzk$Nz;l!z;*##VA23R(eOB|f!+k}|caNuXSL&R;S^qY?r;QH48JFGP(en%r2jTL_fr{^a zhO+!uBf-Ow{(7TXGJGPO@DhlVMDd3GPhmnN%Gk*JObxE9?$AmFSx$@G@b+YwEVA*O~n2444q8tkQ{rH6Y^@v?8OM zFn(O2bL}KJh4`#Shbn}^WFqiV8IKjeN6eIt|bvpZu4?`u$XC)cS65Zllz4o?>hXCx{=?$+_s5aLhf)E@KfY(oA|^Q%u9i7jVYt4N=zQlt}ju z)|LTX&6>fOLg4ku6A+?FgMnucb>(OzgqyY696W?y>vFBV5=9Do+bd*I|W5QQ8s8qXU7|HwBoL-f}!`|4n` z<$bHBV&)#iJuMPWp7@`Qh&<2xwGbH<(sQ1GS}Yd!ou7Xc|0+>d z{rmCHsN>)Ctq+;#1A@~AYQ&yf?e~~ifZO^}lX~ohorV#_(rTI7O@CnDdlp$KMjfi4 zl#rAaT%xzF=B`vCqvW#rr>F+TLk3_3Wd;UtO{G+mQn69?)+!N-UY{f}@KE~4Fdn3$ zC}hd9?95s+@rPqhl0rIC&l(G$@V6wArT`&T87=HTw2dDabzyT^mFQ#wJS1sLq)@mk z8y^XH5~j4-Y(gcc9EeSY0;mfAYX4(>?qBy!?{3u_oqCg!&F0&!l~$|lW?jNdpB{mm znFs-jDbJOD$2D5yI8!A41RqBI(8ONhG02~=k|YrwB0A%ZbX*KXjASLYk7k^WmVd=Rh(GUq03P)urZ1!aBrMgi9 ztjWTPh?%stdCqtW_)90v@JOVLdbDC)o$=-da$mMCt_>L^uR5>`pMbKg_J+yONSh&L z-AAKNQ+=zEuWuFgyz#R!nSJG- zS^DOv)BR~p2RLb&yi8K1`HQ6^E9Td@$!L8+6ZAL@n3655gNb-XB0?wvGmVQSxfd8B z1*QC+Xg{qthUCP9Co7e*w4N4AFqtwcv#B)>zLfF~7*`Sl1+-BTT?V<5IsDy>hr)De zg11P{=;i|@D#n)9Hu+$MZe%hX6{$8;y3mJMruinCbvZg|0xe7Q#je=qG0fl)o|05V zDCH@Q5F%V2#K{j5rd3iG7yb2>f8`e)(*o1+FhsGx7sJls+UOdu1r5mR^p(;gFSG97 z{x78!w1N1%uls0}xq$y4X5_O2ru^FBqSIa*XxtCA-RiVIGH>vl7~Qh%vG4k?!+Gt3 zed9hmeGyq3M_?^iZgJ;+N@#e+$|FOkSDvnY>v}eSxXEi8fd1$oyy+NDxN`+GZX~DZ#eM~qNNRO%kAQx{ zE3@0Zmk|pi6PlJQxmBkqx;SVPUdc?4Gm#PP2)Ad5L zYu-y7w15QUY)Qnv3*^^*=|DDtuV&)wqHP1zr>zcF`ExzOb*}f>tM(UPJ*Q8>>oLQC zFipdIw~o&w2h$6H?9941JQda7ol;)=L*A`7BHkBNMdMuR_e;Ug8jlM~!8rVyE~=%P zb+qb7M*`1(vs#@`u)Ry?r>0l_@_8+unGUO+;MJ7?9ZIpKb-BvAbFWx?ebd)#7=!y9 z3+JoN^ye&f8>xdg7I6|<>ZceQN-@kpXb9zV9FYnT5Hv{A)K6+KVb#C2=Z1+?5KPqc z2C#p|M6w0}DmWD4Erk$j^Z-RCwvr~_nk_;=sF}%3SBN-bd6`Kp^ zl?TJh0LOKFmE1ht%yL%>H6il0u*;Qo{uou_SFlOaJcKw&`Fmm`j5eHS!8@Hu4||N5 zoE%31e$xM)%l34~^vH7&<~YsVy6#j!yBoZ;Pv>~_D^71>|42ge zdF7p30U(!cVj~<^n~#S>Sroqw1se){N%(x01q?z``vuA#3Sbla^heC#d-QjevBU{X z)|s&^n(}+LRE2*3O{8m*4yowXnV2rZlUh}iZ)LY-lnP@s8AB>_QtWgj3NW{TF8o?{ zJ7MX53lS0M?a3Uc8{&#qEINesp?)t_+dcI8Z?>VQse^BX(U7u-M(eMOtq+ys??c;P zV_aKKAuUix1BSp$+m7?oY?IB(j=gxz{CH;HM~3$YLjhFkd|!riCjGsODRvETa=FC> z25YZ6NE4;D8al8tx^OtVl4 zr+0{a82br5w$Z)wkEIVp8@_8qC*VZkrJ4JxC`f+=1A+@9e=y+wVJ%aDf(WK#qdW;x z$!NtQRb%p$-tUL>n7Jn}Xm*8PWRyt6@?!a|w~SF(C4CVk&a;agAI-;6zN|L0S=T#J_L#(3t;-!S@(5e-Zi{spT6^izNpaEvHlQR%Wt- zOJ9gla{FD994w=tlrAh}Rs=x~uG@2J(PBFwy7h=IxBbPSfbWZI;Jvqgv2^vWuwG}! zeZhT5Ey#I`u7DuujQV>~n172S|4$Sw?8q+~)PmDi3?&p0P5IPf_Jw714AFu-vH1pX z??}8@#EBXjYldtk`f_0{+$Wd|x6orCe8EVX?m5(0w-?N=P3{&-oE26^-b`MaQZtN# z<@#1v@7bLL(${QBTh~2Z{6qE+=WEuS&q9k`fS~_^%nvrdIv+AlJIlx3*Cs&%8^I_84L|gm@;x!rcDr5q5*Wx z;Zdp|kqF-iZGXa`CWT(JN{oZ9?i%%2NoX*Hqej7GX-!th;>`Bav$P<<7#FQnlG2MN zA)7#ojfnlu`$0uGi7qh>25&Xvkw*nfD`u3GK&I3SLT2S>7A6h`TThjhBcEk?sDVqG z#EDKfSf5&{P z)$bht8Foe=!IQ8~D7Jbf3@G_Hr3KDBE_OzK+OZM-aZS z?C(bK{6ZqN2GTJEq$<8}>#2k7u$UeE2})`Z*|(>Ob?<{z0Wh?x7$KJ5FS&$ob*(zm zx7x$k-K4#DIrxn!YIDZu)Iy8GGNqaztEnrc9;+JO8U&2O=OwRZ5<~MJ&suGrb6&RI zjJ3Xn%lhdJ4zn5F$aFu(t*>l4#eaNAZbzcWt;BL)h(lnYEWvvoibsa5MW zI7Tt6YY7QED`ooBXCz!WPi@k2(N#J27<3#oZolBI|8?WwijQB*-hPr6bEvBAyafB! zH9x=Gz?N3-u9MNBv2I_cy)^vS<_!vyL~D6t1 zRC8Lf{0dWc12tnyfPDhdb1#;Q5C92_F1KQ;i#DLZBUK|9`L(OAABb0}JlYjKd7yy- zl{M;@Aq!X3TABzWN@fjzEQ(lhO<_Go#u?2bYQmrr_vcC;rb{CF;p$Q$bRT&VcGRwX8& z=0CM>jCIF#aAt3#1Y$T=3?p`;kjt!qO%QhTHR4v);y4 z6Fcm8Y!XM{=00H$Tp;$?vAHuRe)f-XIDw242;|-u10D!son&kV7N)&D{xCY?HZ3|k z9_{Ce*h6mpjYAe+2m|drw*j6~8eD~$G^AryMuUl*;jj0rLjjgyK(nZ1k^(dqycy># zk6c#LGDL3iGiapQNUX|)V?lu@QIzBN{&BgMUSsm}=Gbwr2XI;NgK>`w8{DIIaK|=kPhBnqtvlJ?+^q3YkrBRd$ zVwLJ$@Q&J*ebvb(a92`0JZ|{F+4aidaO2>Aw_a~NzqI1BD!_fyJG`IqJ3emHWBt3B zy?W%qEG9i*ytefO|1z`LJmeoe3MJEsFnKw=Dn4fc4k!DpmU^X;iXqA*!kR0ZlOS>= z#F$!U&4FTRVfLsaJqjZ#s0=R1cwvyT;LI9Kk`)+R6lYG7X=ORCgp9~;Y@kgkX@Xe+ z`;Y86n7+i(;t^#5B`hrE>q^VTyO1f9kY`XVrWcIJ!)($>2GCgh0#@%7Td@Q0~V_Mz8d%5=KjOF?*tJ9dEFh}6h$-1U>kTY z_so^Z&O@dfJl%5vcV4EeW_x$6n45E<&oQaH?`@n`Zm#xt^KQT8+fhKG#}=2dB_sR# zk=Qj0rep-6FoRYCU&>;996I{GlCYVKrKtS=;SUL5gW`xxnsV}eXbSjbiNYwox)uAp zh$!VBjh}xnNG?0#-oC4MPUIB@3W`TT*2|pWud>QG^L7C?=(7*GF*8Aze8I5SFtG_HyIKdu{K0;3oov*81x+oF*h zs;eBv=xe{^4H}lW>Rr2&=aHvgk_f%;ttzA4gSIOPsVU5l;TZ zMw1usr7n=jGNzh{~XhYw{0 zg4eB#A8!t)76h+Yc~48-)5$H>l#lvoi~WjHC!=L7xpmm{@4JC|e)Xo~KFsc;=CK7rqNDY5=YsONax!D2Q5u9X! z5G}CqH774JN9v_06)qDd`zXf-CBLE=`sEIaD^u^U1B z?DyUhZ7!vfV;;(?I4Q-|E2{rB{~zM~zu3k=7r^d<@F6#Q#9;E7GKJtG>?4wSp$-GFCI z12U8U|j zF0+r1gUNZ9=d?}!aAC2>B2!2o{lWHkE#0q#;t4a{)Ts0U9&ws^yOfB-lnc;D~WoO^zPzw7T8-*K+?l`)L4 zC5W9?Vu(ysKz#rI3d_O3gs;B%$STAy&3`iV5u^<=lRcFe*3FYgL(nlc5jeM`mFb{B zMKij!0=B7ORBAy)rbtI`G*}VkSy4>U(Kov6eEt}=FkN<7a9nEDCwXjmVQXx6@SL7o zUG}~3eF{tN(pm@z2!44@KT`AWDu0$o>v<(h5p^SA+c5x5ZtV`df+rXA z$BJU70T6Y?2YGLodZ;I}O#5v%B4iw^;a6Oz6Z>r(&+U}-p-#}$B>==cSzy&=uz2f# zy@0VSHVlb$Owyc1I4#6k96s;{f{9WTG@=D9V!LL0OHo;aWpHgV(qVutHWP#csi>Qu zOt`h<%zO-IWzgRipTJ7{y{D?a0QcVqSB9|f=eIqO=@uJ)#8uklPAjr`!A~E1c3K>d zsTgO1fLQEzdEb3V>SDhm1X67~)WJ}rskS*Pmze~b`N~^{&yRT~wzAy9IM(nO*_2+Yo?#NV++Mytx)}Nox`%Wvy5yn#+VN%s~qnDod zr%XV1icEXP@|09oJKjbV3bQ!rp9m~jfvl1s@DGW7MaS-R1`8et_b?VJ(|sp_s+xhq zQ6YP{Ce&a$0b|KvObT2S8or^C{1^u_kScSOA{K_MfisG5AW;Qs;gS6mNNGiIB(gt4 zU0ljX@pN!Xwqz%E5?E->T2BH+(D_}=s$6kl)P$|uLLx~RWm6%98Uu|6g+(Z|0rHI2 z=+aoAB)(?7Ld!X-!5n6Fo|kCB&gNl4RO6Ki%6;wLN-kq+BU3{Y>;eLS&F_Mp_tURf zuB{cO&hd0+E|~yR=3=zT5-SYRtkG^abkZ+IU*hHedn3BBgT6{4uDB3XmYoesMYUa z)}EckYbmH9`LL4j_{tVT{nVuNCmW|bwzyNk772#s`lqWGtXzNHYz zgn=bEV$i5{2--~&7X7LsqypxYV`zV;NwBuKhBV>KnRktilxMo4_X2Ib`59f#+q@we z)LOmEd~S83_8qXsb9r@qm=TFxX>G7@@39B&dcd zPD#)i(&&(Hge#~oEAn)9o@lXO#j@eD?d5-}=_RDUJS6n4>OReD-e|XrIEFU4@_QWU zR}7#rcn&^#9XEV5;XBe+j2;#^-Q|9~?ynPr1{DRj`#tKfO^_fA8phF-DwJsrbqcNH zwJhrfEw&6so|y)!d@?~vDWWVxi|_cc=JfgC@fFCQe~0X4`AKhba&EC&MReQM`VN0& zYH|iq|K$%`Mhq*kr`3p#OyQnyRmpjFBX4?aPvCJ*!1ZfUmdDkYmO?jlp|Ficb)y-! zk6%@S&OU3Df&@F~F@VO1MxKC2O}IKFXiXeHlEHuxHHpBEdPRtLwF%Uub#Q5>fEqZf z_jeENd^<{27R`7-YG~Y1JcNDXyc^vd4-($?!X=pe1ExflM4vv!m` z(q^+JBc$Xk8ZP3p^kI}0+ce->D%u*WtE~J zsn!%y%~%*A-svQKD+j z3BZJyjZzemLhqOLQJ{kAzXe%97o`ank~p6mJUl8qHf>v4)J0d8S#`~9HQS?_prGWTN^MKSk#s7IM z-gQx11$#xk&T8u?v$<*?R&16K&Vf(&@BMc*o_FTmuQ$CJ_317r{&g2sMR@C4qOgK$ znMOtMo|@C-nnh*t^cSx3swk!55c;9k%Ip+NDdI+0iOw@`yfht zwJF`Z=B;pbIhSoqPu>HDx5Xvohg*$73gM>>e1%P}SLW0!YaI^PQ%$(_Zj)E0zDI}e zX^nyeRSs=Dhf{eM$v~cR3O$aTV%&`KH8FJ@d2`!P70)yVE|6q52${?vM^JY641biF zz!aY(lIlxV3K5i5hPtqUN(NUAULW zPdgVB3HZk#pc%9U?}gWF5WhT;!auOE`3Hj%p&am7{7RzE$c9@Xvqqn*OL6KbTU@xPD)K2eZ+S+>N_}| z7oQH?Y5UwL6U2?@{!k-wp6SZ?!D@S<5v-<4x`-Ch&yT?<4mV+hM$y1&uHVy!RJ%#~ zhItgI@rNz|BCar3)E?UDZt2|N>)^ukUXtnYC-R4wE0o~`ITk-xz{AN^|LK*UTvLQH zp^L43uD_l&|M|$yYzJgI(o!>7VvAy^Lq3GWV!Qs&KSETj6DX`QT%mLwBSe)eV5m^v zmtu&M#p!BDNifV>JsTfg+%0P~vd0)AM!y$;$ziK;!7V3=f+z8stjiJcFQ6_r(flv; zcg*QmJ9b_}?`ol@y2%`07E$S!y|XwO-2>jGpQER{LbnZXyljvC$-S!tQO*KB!9Mp= zktgE?%!Cbi?CR_xo9|*c)HHcyHkuM$j?Yp=Nk;hEM8rnSsNEdYM|IU@&t_GC@Vebr z^!{$U4mXL4U;3RpCAEEq-Tz{5M!P>)Yk`LnO;9@AJ2N4U?;-ITy8$d|3_3AWLcHy6 zns1H-K!dbTJY(@Ngb@{#gd#MYK=uz5IZPFmS{yD4^B9w4?pVk|NSflcas+-#3}>&s z8hKKNd--Igy6Z$lF&3PW2J=OZG4^y&m|5KMkr{{xG{&lbY-8eNLF^PN;cPV4zgty% zyD>KDoWmX6ddco*{R0|fsOX%JCEb8uBDlvXO&6B?nu;jYdI zof+af#<$iP=nKAfCO{`?O3#Y(#v7rqMw@9N5>^zpH0g!0NIv0lahxN-Dz#_jE*#baR?Z{#0qAcg`*6l2kig8?e*&UW!J`Ty+Fe`DQ1)R_S6 zPv9TFuK#f-yTx}lh3>9C)Sq3xQ47pEY6*$IW%gW1o~!T%_fQDuajqkcmO#SYpTlA$ zmDOcjuiA^TtSfJKVj|kKbmp~`piO2<%@FD`f$-^zBDCesE3BFyu-Jcg$U#MfPwMAn zv6^&tk3Qb<=quDx@wjTGf;8HneVC19D~CGVkTjWUCtjlhUQV@IPVq{o2(mX^p`oD4 z22U4lu7kLMI-~Z|Cb;TkB8O6|uKg?df?t`R-v&PH=V3{}kRJ&XvCH}4=J-Khp!anEPQz=>{CO!$PLw>2opKa6h$0Nma2&v>r|{! zbg|>V-ha{sU$fV5$+zy}c8xGgXP>8s0m)G z?ZlY&lDjwZwIq9wc77+6^)7v;WSYmn0zoM_Qd+?*m*_jN;122_XJ;}j|B1>^n%9d? z;dtxxo)e`+0Lr?cLjh(A_%N8y|6+_+l#GEr>rZc8*^6IUr2D)-FP8`0(mx-h zM{N7r(6mNoeiyWT8s)ryTI@7pcSm^Gzu{4P_=YEY08>43-fTa24ydnTeH4Z_QWBGF z5m&W8v3(mr+J1@IwLMl47OjYjkIk779VliP4UHvl>887ZTFb@7--I7<-QI1#BN=>z zw;ZN--n%_-Z2M8#w>@An>9Ks5gdDQuxz5!){hN(2X`zh%!OB;hKzXIG@B>x9^(<4# zr2Ig1A+SrrvWt_ouBa>}rZ@<~yoLO}8rd?lBD?D}DCL3Cu@}MdBXYAa491U_T*`}X zEd!57ga#D+wh6*e!1_s6-xBx16E@f+5vo3PWH-STRNv^rupOnyV-izDJ1>^U91X*{ z#*+W?FK@uq?Vr?f~={2U@z3x7U7134=tgW zLNKQ|&n0TcoE;khWkhKvh>Vt`JkCud(s8VQ%#5&?A(=ktVTATiiY<;T#$pMYp-ef5 zG!q;5pg9lAxk6l0RCXNCd7Yt@l+9{`xenjJEF#;$TvN}2`k?^FyMxC~eG?cdWhcUG z^?!p`-!|kl5$Ic^>^c1Te=UB|t)6*{B` z;O9LG`pv1y{oB$~a>)H%CL=XU!GKykl8Ue6MM0?=Oz7C#PARDdW-a5+0qN?J(Vzu` z27~#PMFvI_77|$bCPp|GF<3Q0pnQ&ISR?wvrn|4d|GatA`qVj)u3$54=F!J1C(R+J z=kWpHzFH5U--GjVws;aQ6AWADtceYS|D{{r5O%wlVpJ@S(?Bb@lraezrO9UgPif5n z20`2dhVTH4T#YGEObKf{9zqD@777iHSybhj=CDda39-lrD_HoXNdzfzgklqRyu<}= z$gExH{`4IB`z>w1Zy;WO6MXczXGa;?WVc&mKXcCozu-*z7J@2+WJu53o&R36T8&^G z+Fv+p^jm_mIqi2IzthCCwCXOmyR?-ifB5BELyI%DnOcLEtd>pph)vgdMyGznfPuwj z17b~mKj5q2zylXkJZ8%#kqKQgD1HU~O}o5Uj2;g;<{^Ic}F~#JxJYRh3m| zD;MD`-wJ}g!R5l0CPN%EX-zpe8qNj%7w>_qiku=hG`Pz1YhzRN&#AI{)oG*7EPvoMXp?F`=|O)r{S`0=maKUdvHFTN5q z9A3z%B?XBe08dV5kj(uKTkqGNqftIa(st@70&m*2H!WQUzi-~Q^=kN+ zzTsj%5(Vy076R9vtqd+7Dk#?i0@-}4Nr#Me>bVTn&$&^SF`5fz*`lh6Dvfa!nO+U?7m18f z{^O{E40$&gCs^7VmLMUTGywQL=8~95A=28GbSDG*7N4~ir&@5O!)^!`%de!>=Hw>g ze#G{sdJIkIv1D>N0Inl4jI1S-y@WGbf^l$Y+WMxrEWcb6>HrDnD7q{Jhnt5}5>Z13 zE+|-t`qEF^bAsHa?^CY-dS^<&jVfK5=OSLe8|!T7z(M9k6U`f{5>x2-Dak(}eX+Z? zpZ<^c_982=c*~bmGL-o*m%@>(j^2qLHhW0pb2H#2b-F}0y>zv4*6B)PzSM5)8+Y=b zzVh8Kdoy19p?-?ca>kBi-mF1SEm6fOM`u-SQ#2Krq}A$8qo`I?bw5+6hOTr;!)k^p z>IP9XA)cN~Hi^i`btlx~u6`*L2Z0TgLXpve-+E{rSHIWT!1&=J=zlh;Xd(r=9x~KR zH`KmLdta*WfpRa%pTOCB1TeXoUO@iYIL%!=Q9+}K1CJU{+MBNhAS^ZiS^ z?@O5X*SFuHI?o6lqCGkv9!@4}10W3^_qrR(1P^XJ|5+g)qDBy-$sRH{S%HJoRq_-} zczB`jPUt-8^v-j#AVXmQc&x3($(C95Bkgl>H+4!S?87v86lq{cX`6e&b)AtlYqtO` zc|NIvpXbr7&#;Dnr|cb$dM2=)SNo1uhx0$SS})!N6<6w$1-{{<^MrrH0>yi@WjG2v z0-im2$5XgNq3kgZ`7v9b>0YehKk@&Rg}H=Q9pr(}X@`yavMh%dA(wm7zI99*t@9yP zI_4~yZ~#N5F`i)>7p2w5s>9pFYUE{j}owg#Hu@eZ?O<*l*|1 zhWn7U#DA8o&9-rt%k_gehj99Jc_wSS6JiIWjdUJ z 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%0Abgraofarrb8qR5_$b8%FB9w~B*fc^V!h!s44< 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 { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/filesjobs/{job_id}/status', + path: { + job_id: data.jobId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Files Batch Status + * Accept a list of file IDs, refresh each file's OCR job status, + * and return the updated list of files. + * @param data The data for the request. + * @param data.requestBody + * @returns FilesPublic Successful Response + * @throws ApiError + */ + public static getFilesBatchStatus(data: FilesGetFilesBatchStatusData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/files/batch/status', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } } export class ItemsService { @@ -365,6 +407,41 @@ export class PrivateService { } } +export class StoragesService { + /** + * Get My Storage Stat + * Return the storage statistics for the current user. + * @returns UserStorageStatPublic Successful Response + * @throws ApiError + */ + public static getMyStorageStat(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/storages/me' + }); + } + + /** + * Update My Storage Stat + * Update the storage statistics for the current user. + * @param data The data for the request. + * @param data.requestBody + * @returns UserStorageStatPublic Successful Response + * @throws ApiError + */ + public static updateMyStorageStat(data: StoragesUpdateMyStorageStatData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/storages/me', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } +} + export class UsersService { /** * Read Users diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 5d032f8e6f..058ae3f657 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -29,6 +29,10 @@ export type FilesPublic = { count: number; }; +export type FilesStatusRequest = { + file_ids: Array<(string)>; +}; + export type HTTPValidationError = { detail?: Array; }; @@ -121,6 +125,25 @@ export type UsersPublic = { count: number; }; +export type UserStorageStatPublic = { + id: string; + user_id: string; + file_count: number; + total_size: number; + total_cost: number; + updated_at: string; + total_transactions: number; + extracted_pages?: (number | null); +}; + +export type UserStorageStatUpdate = { + file_count?: (number | null); + total_size?: (number | null); + total_cost?: (number | null); + total_transactions?: (number | null); + extracted_pages?: (number | null); +}; + export type UserUpdate = { email?: (string | null); password?: (string | null); @@ -180,7 +203,19 @@ export type FilesDownloadTableExcelFileData = { fileId: string; }; -export type FilesDownloadTableExcelFileResponse = Blob; +export type FilesDownloadTableExcelFileResponse = (unknown); + +export type FilesGetJobStatusEndpointData = { + jobId: string; +}; + +export type FilesGetJobStatusEndpointResponse = (unknown); + +export type FilesGetFilesBatchStatusData = { + requestBody: FilesStatusRequest; +}; + +export type FilesGetFilesBatchStatusResponse = (FilesPublic); export type ItemsReadItemsData = { limit?: number; @@ -246,6 +281,14 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = (UserPublic); +export type StoragesGetMyStorageStatResponse = (UserStorageStatPublic); + +export type StoragesUpdateMyStorageStatData = { + requestBody: UserStorageStatUpdate; +}; + +export type StoragesUpdateMyStorageStatResponse = (UserStorageStatPublic); + export type UsersReadUsersData = { limit?: number; skip?: number; diff --git a/frontend/src/components/Common/Logo.tsx b/frontend/src/components/Common/Logo.tsx index 05c299f4b5..148c618038 100644 --- a/frontend/src/components/Common/Logo.tsx +++ b/frontend/src/components/Common/Logo.tsx @@ -4,8 +4,8 @@ import { useTheme } from "@/components/theme-provider" import { cn } from "@/lib/utils" import icon from "/assets/images/fastapi-icon.svg" import iconLight from "/assets/images/fastapi-icon-light.svg" -import logo from "/assets/images/fastapi-logo.svg" -import logoLight from "/assets/images/fastapi-logo-light.svg" +import logo from "/assets/images/logo_dark.png" +import logoLight from "/assets/images/logo_light.png" interface LogoProps { variant?: "full" | "icon" | "responsive" @@ -29,17 +29,17 @@ export function Logo({ <> FastAPI @@ -47,8 +47,11 @@ export function Logo({ ) : ( FastAPI ) diff --git a/frontend/src/components/FileHistoryTable.tsx b/frontend/src/components/FileHistoryTable.tsx index 0e2ad3f434..7a6bd17095 100644 --- a/frontend/src/components/FileHistoryTable.tsx +++ b/frontend/src/components/FileHistoryTable.tsx @@ -1,4 +1,5 @@ import { useSuspenseQuery } from "@tanstack/react-query" +import dayjs from "dayjs" import { Download, Eye, FileText } from "lucide-react" import { FilesService } from "@/client" import { Button } from "@/components/ui/button" @@ -11,9 +12,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { StatusBadge } from "./StatusBadge" -import dayjs from "dayjs" import { DateTimeFormat } from "@/utils" +import { StatusBadge } from "./StatusBadge" function getRecentUploadFilesQueryOptions() { return { @@ -23,8 +23,8 @@ function getRecentUploadFilesQueryOptions() { } const fileSizeInMB = (sizeInBytes: number | null | undefined) => { - if (sizeInBytes == null) return "N/A"; - return (sizeInBytes / (1024 * 1024)).toFixed(2) + " MB" + if (sizeInBytes == null) return "N/A" + return `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB` } export function FileHistoryTable() { @@ -65,7 +65,9 @@ export function FileHistoryTable() { {file.filename}

- {fileSizeInMB(file.size)} + + {fileSizeInMB(file.size)} + ) => { setError(null) + const files = Array.from(e.target.files ?? []) if (files.length === 0) return + // webkitRelativePath is "folderName/file.pdf" when a directory is picked + const firstRelative = files[0].webkitRelativePath + const folderName = firstRelative ? firstRelative.split("/")[0] : null + const validationError = runValidation(files) if (validationError) { setError(validationError) return } - commit(files, null) + commit(files, folderName) } /** Validates a list of files against all rules. Returns an error string or null. */ @@ -197,7 +202,9 @@ export function FileUploadDropzone({ onFilesSelect }: FileUploadDropzoneProps) {
-

Upload bank statement

+

+ Upload bank statement +

Drag and drop a file or folder here, or click to browse

@@ -237,8 +244,7 @@ export function FileUploadDropzone({ onFilesSelect }: FileUploadDropzoneProps) {

- Accepted: images (JPEG, PNG, WebP, …) or PDF · Max 100 MB per - file + Accepted: images (JPEG, PNG, WebP, …) or PDF · Max 100 MB per file
Folders must contain only images or only PDFs

diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index 1551777774..72d5d0b587 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -1,14 +1,14 @@ import type { ColumnDef } from "@tanstack/react-table" +import dayjs from "dayjs" import { Check, DownloadIcon, Loader2, RefreshCcw } from "lucide-react" import { useState } from "react" - import { type FilePublic, FilesService } from "@/client" import { OpenAPI } from "@/client/core/OpenAPI" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" -import { StatusBadge } from "../StatusBadge" -import dayjs from "dayjs" import { DateTimeFormat } from "@/utils" +import { StatusBadge } from "../StatusBadge" + async function downloadExcel(fileId: string, filename: string) { const token = typeof OpenAPI.TOKEN === "function" @@ -132,23 +132,23 @@ export const columns: ColumnDef[] = [ return (
{(file.job_status === "running" || file.job_status === "pending") && ( - + )} {file.job_status === "done" && ( <>
-
+
)} diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index a0838c914d..91f18c2fac 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Briefcase, Files, Home, Users } from "lucide-react" +import { Files, Home, Users } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" diff --git a/frontend/src/components/loading-spinner-provider.tsx b/frontend/src/components/loading-spinner-provider.tsx index d4e032b0ab..8a8c0557ad 100644 --- a/frontend/src/components/loading-spinner-provider.tsx +++ b/frontend/src/components/loading-spinner-provider.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useState } from "react" -import { Spinner } from "./ui/spinner" import { cn } from "@/lib/utils" +import { Spinner } from "./ui/spinner" type LoadingSpinnerContextType = { isLoading: boolean diff --git a/frontend/src/routes/_layout/dashboard.tsx b/frontend/src/routes/_layout/dashboard.tsx index a66096af9f..80bb8acc5e 100644 --- a/frontend/src/routes/_layout/dashboard.tsx +++ b/frontend/src/routes/_layout/dashboard.tsx @@ -1,58 +1,65 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { ArrowRight } from "lucide-react"; -import { useState } from "react"; -import { FilesService } from "@/client"; -import { FileHistoryTable } from "@/components/FileHistoryTable"; -import { FileUploadDropzone } from "@/components/FileUploadDropzone"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import useCustomToast from "@/hooks/useCustomToast"; -import { handleError } from "@/utils"; -import { useLoadingSpinner } from "@/components/loading-spinner-provider"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { ArrowRight } from "lucide-react" +import { useState } from "react" +import { FilesService, StoragesService } from "@/client" +import { FileHistoryTable } from "@/components/FileHistoryTable" +import { FileUploadDropzone } from "@/components/FileUploadDropzone" +import { useLoadingSpinner } from "@/components/loading-spinner-provider" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError, formatBytes } from "@/utils" +import { FilesTableContent } from "./files" export const Route = createFileRoute("/_layout/dashboard")({ component: Dashboard, -}); +}) function Dashboard() { - const queryClient = useQueryClient(); - const { showSuccessToast, showErrorToast } = useCustomToast(); - const { showSpinner, hideSpinner } = useLoadingSpinner(); - const [selectedFiles, setSelectedFiles] = useState([]); + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const { showSpinner, hideSpinner } = useLoadingSpinner() + const [selectedFiles, setSelectedFiles] = useState([]) + + const { data: storageStat } = useQuery({ + queryKey: ["storageStat"], + queryFn: () => StoragesService.getMyStorageStat(), + }) const uploadMutation = useMutation({ mutationFn: async (files: File[]) => { for (const file of files) { - await FilesService.uploadFileEndpoint({ formData: { file } }); + showSpinner(`Uploading "${file.name}"...`) + await FilesService.uploadFileEndpoint({ formData: { file } }) } + hideSpinner() }, onSuccess: () => { - const count = selectedFiles.length; + const count = selectedFiles.length showSuccessToast( count === 1 ? "File uploaded successfully." : `${count} files uploaded successfully.`, - ); - setSelectedFiles([]); - queryClient.invalidateQueries({ queryKey: ["files"] }); + ) + setSelectedFiles([]) + queryClient.invalidateQueries({ queryKey: ["files"] }) }, onError: handleError.bind(showErrorToast), - }); + }) const handleFilesSelect = (files: File[]) => { - setSelectedFiles(files); - }; + setSelectedFiles(files) + } const handleUpload = () => { - if (selectedFiles.length === 0) return; - showSpinner("Uploading and processing your file..."); + if (selectedFiles.length === 0) return uploadMutation.mutate(selectedFiles, { onSettled: () => { - hideSpinner(); + hideSpinner() }, - }); - }; + }) + } return (
@@ -83,7 +90,9 @@ function Dashboard() { {/* Convert Button */}
@@ -142,15 +153,31 @@ function Dashboard() {
Total Files - 48 + + {storageStat?.file_count ?? "—"} +
Total Transactions - 2,847 + + {storageStat?.total_transactions?.toLocaleString() ?? "—"} +
Storage Used - 245 MB + + {storageStat + ? formatBytes(storageStat.total_size) + : "—"} + +
+
+ Total Cost + + {storageStat + ? `$${storageStat.total_cost.toFixed(2)}` + : "—"} +
@@ -166,5 +193,5 @@ function Dashboard() {
- ); + ) } diff --git a/frontend/src/routes/_layout/files.tsx b/frontend/src/routes/_layout/files.tsx index 0a89a9c730..e5d1b0f302 100644 --- a/frontend/src/routes/_layout/files.tsx +++ b/frontend/src/routes/_layout/files.tsx @@ -1,4 +1,4 @@ -import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query" +import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" import { Search } from "lucide-react" import { Suspense, useEffect, useRef } from "react" @@ -16,8 +16,6 @@ function getFilesQueryOptions(limit = 0) { } } - - export const Route = createFileRoute("/_layout/files")({ component: Files, head: () => ({ @@ -29,9 +27,9 @@ export const Route = createFileRoute("/_layout/files")({ }), }) -function FilesTableContent() { +export function FilesTableContent({ limit = 0 }: { limit?: number }) { const queryClient = useQueryClient() - const { data: files } = useSuspenseQuery(getFilesQueryOptions()) + const { data: files } = useSuspenseQuery(getFilesQueryOptions(limit)) const pollingRef = useRef | null>(null) useEffect(() => { @@ -41,6 +39,7 @@ function FilesTableContent() { ) if (pendingFiles.length === 0) { + console.log('No pending files, stopping polling.') if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null @@ -48,18 +47,18 @@ function FilesTableContent() { return } - await Promise.allSettled( - pendingFiles.map(async (file) => { - const updated = await FilesService.getFileStatus({ fileId: file.id }) - queryClient.setQueryData(["files"], (old: typeof files | undefined) => { - if (!old) return old - return { - ...old, - data: old.data.map((f) => (f.id === updated.id ? updated : f)), - } - }) - }), - ) + const result = await FilesService.getFilesBatchStatus({ + requestBody: { file_ids: pendingFiles.map((f) => f.id) }, + }) + + queryClient.setQueryData(["files"], (old: typeof files | undefined) => { + if (!old) return old + const updatedMap = new Map(result.data.map((f) => [f.id, f])) + return { + ...old, + data: old.data.map((f) => updatedMap.get(f.id) ?? f), + } + }) } pollingRef.current = setInterval(pollPendingFiles, 3000) diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 90197c9737..1444f4ee4f 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -30,4 +30,12 @@ export const getInitials = (name: string): string => { .toUpperCase() } -export const DateTimeFormat = "hh:mm A, MMMM d, YYYY" \ No newline at end of file +export const DateTimeFormat = "hh:mm A, MMMM d, YYYY" + +export function formatBytes(bytes: number, decimals = 1): string { + if (bytes === 0) return "0 B" + const k = 1024 + const units = ["B", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${units[i]}` +} From 3ad06118037e72327168580ae40366dcdf4efa24 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Tue, 31 Mar 2026 01:13:05 +0700 Subject: [PATCH 07/30] ready to update --- frontend/package.json | 1 + frontend/src/components/Files/columns.tsx | 3 +- frontend/src/components/faq.tsx | 75 +++ frontend/src/components/features.tsx | 68 +++ frontend/src/components/footer.tsx | 79 ++++ frontend/src/components/header.tsx | 59 +++ frontend/src/components/hero.tsx | 112 +++++ frontend/src/components/how-it-works.tsx | 63 +++ frontend/src/components/testimonials.tsx | 68 +++ .../src/components/theme-provider copy.tsx | 11 + frontend/src/components/ui/dialog.tsx | 32 +- frontend/src/routeTree.gen.ts | 21 - frontend/src/routes/_layout/dashboard.tsx | 4 +- frontend/src/routes/_public.tsx | 2 +- frontend/src/routes/_public/home.tsx | 444 ++++++------------ frontend/src/routes/_public/pricing.tsx | 305 ------------ package-lock.json | 254 ++++++++++ 17 files changed, 945 insertions(+), 656 deletions(-) create mode 100644 frontend/src/components/faq.tsx create mode 100644 frontend/src/components/features.tsx create mode 100644 frontend/src/components/footer.tsx create mode 100644 frontend/src/components/header.tsx create mode 100644 frontend/src/components/hero.tsx create mode 100644 frontend/src/components/how-it-works.tsx create mode 100644 frontend/src/components/testimonials.tsx create mode 100644 frontend/src/components/theme-provider copy.tsx delete mode 100644 frontend/src/routes/_public/pricing.tsx diff --git a/frontend/package.json b/frontend/package.json index 84bd781bfa..492fa79034 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index 72d5d0b587..c250a8afa1 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" +import { FaFileExcel } from "react-icons/fa6" async function downloadExcel(fileId: string, filename: string) { const token = @@ -147,7 +148,7 @@ export const columns: ColumnDef[] = [ {file.job_status === "done" && ( <>
- +
diff --git a/frontend/src/components/faq.tsx b/frontend/src/components/faq.tsx new file mode 100644 index 0000000000..928bceeced --- /dev/null +++ b/frontend/src/components/faq.tsx @@ -0,0 +1,75 @@ +'use client' + +import React from 'react' +import * as Accordion from '@radix-ui/react-accordion' + +const faqs = [ + { + question: 'What is PDF Guru?', + answer: 'PDF Guru is an all-in-one online PDF converter that lets you transform PDF files into various formats, including Excel (XLSX). No installation or technical skills required.' + }, + { + question: 'Is PDF Guru safe to use?', + answer: 'Yes, absolutely. We use advanced encryption to ensure your sensitive data stays secure throughout the entire process. Your privacy is our priority.' + }, + { + question: 'Is PDF Guru available as a subscription or one-time purchase?', + answer: 'Your first file is always free to convert. After that, we offer flexible subscription plans to meet your needs.' + }, + { + question: 'How to edit PDF files using PDF Guru?', + answer: 'PDF Guru provides intuitive editing tools. Simply upload your PDF, use our editor to make changes, and download your modified file.' + }, + { + question: 'What file types does PDF Guru support?', + answer: 'PDF Guru supports conversion to and from PDF, Excel, Word, PowerPoint, and many other popular document formats.' + }, + { + question: 'What should I do if I encounter problems?', + answer: 'Our friendly support team is here to help. Contact us directly through the support form, and we'll get back to you quickly with a solution.' + } +] + +export default function FAQ() { + return ( +
+
+
+

+ Frequently Asked Questions +

+

+ Find answers to common questions +

+
+ + + {faqs.map((faq, index) => ( + + + + {faq.question} + + + + + + + {faq.answer} + + + ))} + +
+
+ ) +} diff --git a/frontend/src/components/features.tsx b/frontend/src/components/features.tsx new file mode 100644 index 0000000000..66e6310fe2 --- /dev/null +++ b/frontend/src/components/features.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +const features = [ + { + icon: '📄', + title: 'Multiple File Types', + description: 'Convert bank statements from PDF and image formats. Supports JPG, PNG, and PDF uploads.' + }, + { + icon: '🔒', + title: 'Bank-Grade Security', + description: 'Your financial data is encrypted and never stored on our servers. Complete privacy guaranteed.' + }, + { + icon: '⚡', + title: 'Instant Conversion', + description: 'Process your bank statements in seconds. Get organized Excel files ready to use immediately.' + }, + { + icon: '✅', + title: 'Accurate Data Extraction', + description: 'Preserves all transaction details, amounts, and dates with 99.9% accuracy. No data loss.' + }, + { + icon: '📊', + title: 'Ready for Analysis', + description: 'Converted Excel files are fully formatted and compatible with all spreadsheet applications.' + }, + { + icon: '🚀', + title: 'Zero Setup Required', + description: 'No installation, no registration required. Start converting your bank statements instantly.' + } +] + +export default function Features() { + return ( +
+
+
+

+ Why Choose KeToanAuto +

+

+ The smartest way to organize your bank statements +

+
+ +
+ {features.map((feature, index) => ( +
+
{feature.icon}
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx new file mode 100644 index 0000000000..8dca4edc09 --- /dev/null +++ b/frontend/src/components/footer.tsx @@ -0,0 +1,79 @@ +import React from 'react' + +export default function Footer() { + return ( +
+ ) +} diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx new file mode 100644 index 0000000000..57ae61efac --- /dev/null +++ b/frontend/src/components/header.tsx @@ -0,0 +1,59 @@ +import { Link } from '@tanstack/react-router' +import { Appearance } from './Common/Appearance' +import { isLoggedIn } from '@/hooks/useAuth' +import { ArrowRight } from 'lucide-react' + +export default function Header() { + console.log("Header rendered, isLoggedIn:", isLoggedIn()) + return ( +
+
+
+
+ +
+ {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + + + +
+ KeToanAuto + + +
+
+ + + {!isLoggedIn() ? ( + <> + + Sign In + + + Sign Up + + + ) : ( + +
+ Dashboard + +
+ + )} +
+
+
+
+ ) +} diff --git a/frontend/src/components/hero.tsx b/frontend/src/components/hero.tsx new file mode 100644 index 0000000000..b272a4873a --- /dev/null +++ b/frontend/src/components/hero.tsx @@ -0,0 +1,112 @@ +import React, { useState, useRef } from 'react' + +interface HeroProps { + onOpenDialog: () => void +} + +export default function Hero({ onOpenDialog }: HeroProps) { + const [isDrag, setIsDrag] = useState(false) + const fileInputRef = useRef(null) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDrag(true) + } + + const handleDragLeave = () => { + setIsDrag(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDrag(false) + const files = e.dataTransfer.files + if (files.length > 0) { + const file = files[0] + const isValidType = file.type === 'application/pdf' || file.type.startsWith('image/') + if (isValidType) { + console.log('[v0] File selected:', file.name) + } + } + } + + return ( +
+
+
+

+ Convert Bank Statements to Excel +

+

+ Upload your PDF or image bank statements and get organized Excel files instantly +

+ +
+
fileInputRef.current?.click()} + className={`cursor-pointer rounded-2xl border-2 border-dashed p-16 sm:p-20 text-center transition-all ${ + isDrag + ? 'border-primary bg-primary/10 shadow-2xl scale-105' + : 'border-primary/40 bg-primary/5 hover:bg-primary/8 hover:border-primary/60 shadow-lg hover:shadow-2xl' + }`} + > +
+ + + +
+

+ Drag and drop your bank statement +

+

+ or click to browse +

+

+ Supports PDF and images (JPG, PNG) up to 100 MB +

+
+ +
+ +
+
+
+ + + +
+

Instant Conversion

+

PDFs & images to Excel

+
+
+
+ + + +
+

Bank-Grade Security

+

Your data is encrypted

+
+
+
+ + + +
+

100% Accurate

+

Preserves all data

+
+
+
+
+
+ ) +} diff --git a/frontend/src/components/how-it-works.tsx b/frontend/src/components/how-it-works.tsx new file mode 100644 index 0000000000..3a72608ee0 --- /dev/null +++ b/frontend/src/components/how-it-works.tsx @@ -0,0 +1,63 @@ +import React from 'react' + +const steps = [ + { + number: '1', + title: 'Upload Your Bank Statement', + description: 'Drag and drop your PDF or image bank statement. Supports JPG, PNG, and PDF formats.' + }, + { + number: '2', + title: 'We Extract the Data', + description: 'Our AI-powered system analyzes and extracts all transaction details automatically in seconds.' + }, + { + number: '3', + title: 'Download as Excel', + description: 'Your bank statement is now organized in an Excel file, ready for analysis and archiving.' + } +] + +export default function HowItWorks() { + return ( +
+
+
+

+ How KeToanAuto Works +

+

+ Three simple steps to organize your bank statements +

+
+ +
+ {steps.map((step, index) => ( +
+ {index < steps.length - 1 && ( +
+ )} +
+
+ {step.number} +
+

+ {step.title} +

+

+ {step.description} +

+
+
+ ))} +
+ +
+

+ Convert your first bank statement for free. Unlock unlimited conversions with KeToanAuto Pro. +

+
+
+
+ ) +} diff --git a/frontend/src/components/testimonials.tsx b/frontend/src/components/testimonials.tsx new file mode 100644 index 0000000000..2e35f92037 --- /dev/null +++ b/frontend/src/components/testimonials.tsx @@ -0,0 +1,68 @@ +import React from 'react' + +const testimonials = [ + { + name: 'Sarah Martinez', + role: 'Business Manager', + content: 'Easy to use, good service. Exactly what I needed for converting my documents.', + rating: 5 + }, + { + name: 'James Chen', + role: 'Freelance Designer', + content: 'Great experience, high quality service! The conversion was perfect and very fast.', + rating: 5 + }, + { + name: 'Emily Rodriguez', + role: 'Data Analyst', + content: 'Good Service, Very good customer service! They helped me when I had questions.', + rating: 5 + } +] + +export default function Testimonials() { + return ( +
+
+
+

+ What Users Are Saying +

+

+ Trusted by thousands of users worldwide +

+
+ +
+ {testimonials.map((testimonial, index) => ( +
+
+ {Array.from({ length: testimonial.rating }).map((_, i) => ( + + ))} +
+

+ "{testimonial.content}" +

+
+

+ {testimonial.name} +

+

+ {testimonial.role} +

+
+
+ ✓ Verified +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/theme-provider copy.tsx b/frontend/src/components/theme-provider copy.tsx new file mode 100644 index 0000000000..55c2f6eb60 --- /dev/null +++ b/frontend/src/components/theme-provider copy.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 6cb123b385..243fb1983f 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -1,8 +1,10 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { XIcon } from "lucide-react" +'use client' -import { cn } from "@/lib/utils" +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { XIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' function Dialog({ ...props @@ -36,8 +38,8 @@ function DialogOverlay({ @@ -58,8 +60,8 @@ function DialogContent({ @@ -78,23 +80,23 @@ function DialogContent({ ) } -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { return (
) } -function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { return (
@@ -108,7 +110,7 @@ function DialogTitle({ return ( ) @@ -121,7 +123,7 @@ function DialogDescription({ return ( ) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 2b42ebd6db..5e7a77332d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,7 +16,6 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as PublicRouteImport } from './routes/_public' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' -import { Route as PublicPricingRouteImport } from './routes/_public/pricing' import { Route as PublicHomeRouteImport } from './routes/_public/home' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' @@ -57,11 +56,6 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) -const PublicPricingRoute = PublicPricingRouteImport.update({ - id: '/pricing', - path: '/pricing', - getParentRoute: () => PublicRoute, -} as any) const PublicHomeRoute = PublicHomeRouteImport.update({ id: '/home', path: '/home', @@ -105,7 +99,6 @@ export interface FileRoutesByFullPath { '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute '/home': typeof PublicHomeRoute - '/pricing': typeof PublicPricingRoute } export interface FileRoutesByTo { '/': typeof LayoutIndexRoute @@ -119,7 +112,6 @@ export interface FileRoutesByTo { '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute '/home': typeof PublicHomeRoute - '/pricing': typeof PublicPricingRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -135,7 +127,6 @@ export interface FileRoutesById { '/_layout/items': typeof LayoutItemsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_public/home': typeof PublicHomeRoute - '/_public/pricing': typeof PublicPricingRoute '/_layout/': typeof LayoutIndexRoute } export interface FileRouteTypes { @@ -152,7 +143,6 @@ export interface FileRouteTypes { | '/items' | '/settings' | '/home' - | '/pricing' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -166,7 +156,6 @@ export interface FileRouteTypes { | '/items' | '/settings' | '/home' - | '/pricing' id: | '__root__' | '/_layout' @@ -181,7 +170,6 @@ export interface FileRouteTypes { | '/_layout/items' | '/_layout/settings' | '/_public/home' - | '/_public/pricing' | '/_layout/' fileRoutesById: FileRoutesById } @@ -245,13 +233,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } - '/_public/pricing': { - id: '/_public/pricing' - path: '/pricing' - fullPath: '/pricing' - preLoaderRoute: typeof PublicPricingRouteImport - parentRoute: typeof PublicRoute - } '/_public/home': { id: '/_public/home' path: '/home' @@ -320,12 +301,10 @@ const LayoutRouteWithChildren = interface PublicRouteChildren { PublicHomeRoute: typeof PublicHomeRoute - PublicPricingRoute: typeof PublicPricingRoute } const PublicRouteChildren: PublicRouteChildren = { PublicHomeRoute: PublicHomeRoute, - PublicPricingRoute: PublicPricingRoute, } const PublicRouteWithChildren = diff --git a/frontend/src/routes/_layout/dashboard.tsx b/frontend/src/routes/_layout/dashboard.tsx index 80bb8acc5e..80de88834b 100644 --- a/frontend/src/routes/_layout/dashboard.tsx +++ b/frontend/src/routes/_layout/dashboard.tsx @@ -130,7 +130,7 @@ function Dashboard() { {/* Sidebar Stats */}
- + {/*

12

@@ -146,7 +146,7 @@ function Dashboard() { 60% of monthly quota

-
+
*/}

Quick Stats

diff --git a/frontend/src/routes/_public.tsx b/frontend/src/routes/_public.tsx index 0d5fa883d3..eec3520062 100644 --- a/frontend/src/routes/_public.tsx +++ b/frontend/src/routes/_public.tsx @@ -8,7 +8,7 @@ export const Route = createFileRoute("/_public")({ function PublicLayout() { return (
- + {/* */}
) diff --git a/frontend/src/routes/_public/home.tsx b/frontend/src/routes/_public/home.tsx index a21e70c485..d7c01a22b4 100644 --- a/frontend/src/routes/_public/home.tsx +++ b/frontend/src/routes/_public/home.tsx @@ -1,7 +1,15 @@ -import { createFileRoute, Link } from "@tanstack/react-router" -import { ArrowRight, Check, Clock, FileText, Lock, Zap } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Card } from "@/components/ui/card" +/** biome-ignore-all lint/a11y/noStaticElementInteractions: */ +import { createFileRoute } from "@tanstack/react-router" +import { useRef, useState } from "react" +import Header from "@/components/header" +import Hero from "@/components/hero" +import Features from "@/components/features" +import HowItWorks from "@/components/how-it-works" +import Testimonials from "@/components/testimonials" +import FAQ from "@/components/faq" +import Footer from "@/components/footer" +import * as Dialog from '@radix-ui/react-dialog' +import useAuth from "@/hooks/useAuth" export const Route = createFileRoute("/_public/home")({ component: Home, @@ -9,319 +17,133 @@ export const Route = createFileRoute("/_public/home")({ meta: [{ title: "BankToExcel - Convert Bank Statements to Excel" }], }), }) - function Home() { - const features = [ - { - icon: Zap, - title: "Lightning Fast", - description: - "Convert your bank statements to Excel in seconds, not minutes", - }, - { - icon: Lock, - title: "Secure & Private", - description: - "Your data is encrypted and deleted immediately after conversion", - }, - { - icon: Clock, - title: "Batch Processing", - description: "Upload multiple files and process them simultaneously", - }, - { - icon: FileText, - title: "Auto-Detection", - description: - "Automatically detects and formats data from any bank statement", - }, - ] - - const benefits = [ - "Supports all major Vietnamese banks", - "Customizable Excel templates", - "Transaction categorization", - "Monthly reconciliation reports", - "API integration available", - "Dedicated support", - ] - + const [isOpen, setIsOpen] = useState(false) + const [fileName, setFileName] = useState(null) return ( - <> - {/* Hero Section */} -
-
-
-

- 🚀 New feature: Batch processing for unlimited files -

-
- -

- Convert Bank Statements to Excel in Seconds -

- -

- Stop wasting time manually copying bank transactions. BankToExcel - converts your statements to perfectly formatted Excel files - instantly. Built for accountants in Vietnam. -

- -
- - - - -
- -
-

- Trusted by accountants and finance teams -

-
- {["Vietcombank", "Techcombank", "BIDV", "VietinBank", "ACB"].map( - (bank) => ( -
- {bank} -
- ), - )} -
-
-
-
- - {/* Features Section */} -
-
-
-

- Why choose BankToExcel? -

-

- Designed specifically for Vietnamese accountants and finance - professionals -

-
- -
- {features.map((feature) => { - const Icon = feature.icon - return ( - - -

- {feature.title} -

-

{feature.description}

-
- ) - })} -
-
-
- - {/* Benefits Section */} -
-
-
-

- Everything you need for efficient accounting -

-

- From individual freelancers to large accounting firms, BankToExcel - provides all the tools you need to streamline your workflow. -

-
    - {benefits.map((benefit) => ( -
  • - - {benefit} -
  • - ))} -
-
- -
-
-
-

- Original PDF Statement -

-
-

- Vietcombank Statement - May 2024 -

-

Account: ***4567

-

- Opening Balance: 50,000,000 VND +

+
+ setIsOpen(true)} /> + + + + +
+ + + + + + + Upload Bank Statement + + + Upload a PDF or image of your bank statement to convert to Excel (up to 100 MB) + + + setFileName(name)} /> + + {fileName && ( +
+
+

+ Selected file: {fileName}

+ {/** biome-ignore lint/a11y/useButtonType: */} +
-
- -
-
-

- Converted Excel File -

-
-

- 📊 transactions.xlsx -

-

- ✓ Formatted columns -

-

- ✓ Categorized entries -

-
-
-
-
-
-
- - {/* Stats Section */} -
-
-
-
-

- 500+ -

-

Files Converted Daily

-
-
-

- 98% -

-

Customer Satisfaction

-
-
-

- <5s -

-

Average Processing Time

-
-
-
-
- - {/* CTA Section */} -
-
-

- Ready to simplify your accounting? -

-

- Join hundreds of accountants and finance teams saving hours every - week -

-
- - - - - - -
-
-
+ )} + + + {/** biome-ignore lint/a11y/useButtonType: */} + + + + + +
+ ) +} - {/* Footer */} -
-
-
-
-

Product

-
    -
  • - - Features - -
  • -
  • - - Pricing - -
  • -
  • - - Security - -
  • -
-
-
-

Company

-
    -
  • - - About - -
  • -
  • - - Blog - -
  • -
  • - - Contact - -
  • -
-
-
-

Legal

-
    -
  • - - Privacy - -
  • -
  • - - Terms - -
  • -
-
-
-

Support

-
    -
  • - - Help Center - -
  • -
  • - - Status - -
  • -
-
-
+function FileUploadZone({ onFileSelect }: { onFileSelect: (name: string) => void }) { + const [isDrag, setIsDrag] = useState(false) + const fileInputRef = useRef(null) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDrag(true) + } + + const handleDragLeave = () => { + setIsDrag(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDrag(false) + const files = e.dataTransfer.files + if (files.length > 0) { + const file = files[0] + const isValidType = file.type === 'application/pdf' || file.type.startsWith('image/') + if (isValidType) { + onFileSelect(file.name) + } + } + } + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + onFileSelect(files[0].name) + } + } -
-

- © 2024 BankToExcel. All rights reserved. -

-

- Made for accountants in Vietnam 🇻🇳 -

-
-
-
- + return ( +
+ {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} +
fileInputRef.current?.click()} + className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-all ${ + isDrag + ? 'border-primary bg-secondary' + : 'border-border bg-muted hover:bg-secondary' + }`} + > + {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + + + +

+ Drag and drop your bank statement here +

+

+ PDF or image (JPG, PNG) +

+

+ Size up to 100 MB +

+
+ +
) } diff --git a/frontend/src/routes/_public/pricing.tsx b/frontend/src/routes/_public/pricing.tsx deleted file mode 100644 index c932ade79c..0000000000 --- a/frontend/src/routes/_public/pricing.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { createFileRoute, Link } from "@tanstack/react-router" -import { Check } from "lucide-react" -import { PricingCard } from "@/components/PricingCard" -import { Button } from "@/components/ui/button" -import { Card } from "@/components/ui/card" -import { pricingTiers } from "@/lib/mock-data" - -export const Route = createFileRoute("/_public/pricing")({ - component: Pricing, - head: () => ({ - meta: [{ title: "Pricing - BankToExcel" }], - }), -}) - -function Pricing() { - const features = [ - { - category: "File Processing", - items: [ - { - name: "Files per month", - starter: "5", - professional: "Unlimited", - enterprise: "Unlimited", - }, - { - name: "File size limit", - starter: "2 MB", - professional: "50 MB", - enterprise: "Unlimited", - }, - { - name: "Processing speed", - starter: "Standard", - professional: "Fast", - enterprise: "Priority", - }, - ], - }, - { - category: "Features", - items: [ - { - name: "Basic conversion", - starter: true, - professional: true, - enterprise: true, - }, - { - name: "Advanced formatting", - starter: false, - professional: true, - enterprise: true, - }, - { - name: "Transaction categorization", - starter: false, - professional: true, - enterprise: true, - }, - { - name: "Custom templates", - starter: false, - professional: true, - enterprise: true, - }, - { - name: "API access", - starter: false, - professional: true, - enterprise: true, - }, - { - name: "Batch processing", - starter: false, - professional: true, - enterprise: true, - }, - ], - }, - { - category: "Support", - items: [ - { - name: "Email support", - starter: true, - professional: true, - enterprise: true, - }, - { - name: "Priority support", - starter: false, - professional: true, - enterprise: true, - }, - { - name: "Dedicated account manager", - starter: false, - professional: false, - enterprise: true, - }, - { - name: "Phone support", - starter: false, - professional: false, - enterprise: true, - }, - ], - }, - ] - - const faqs = [ - { - question: "Can I change my plan anytime?", - answer: - "Yes, you can upgrade or downgrade your plan at any time. Changes take effect immediately.", - }, - { - question: "Do you offer a refund policy?", - answer: - "We offer a 30-day money-back guarantee if you're not satisfied with our service.", - }, - { - question: "What banks do you support?", - answer: - "We support all major Vietnamese banks including Vietcombank, Techcombank, BIDV, VietinBank, ACB, and more.", - }, - { - question: "Is my data secure?", - answer: - "Yes, all data is encrypted in transit and at rest. We delete files immediately after conversion.", - }, - { - question: "Do you offer enterprise plans?", - answer: - "Yes, we have custom enterprise plans for large organizations. Contact our sales team for details.", - }, - { - question: "Can I use the API?", - answer: - "API access is included with Professional and Enterprise plans. See our documentation for more details.", - }, - ] - - return ( - <> - {/* Pricing Header */} -
-
-

- Simple, Transparent Pricing -

-

- Choose the plan that's right for you. No hidden fees, cancel - anytime. -

-
- - {/* Pricing Cards */} -
- {pricingTiers.map((tier) => ( - - ))} -
- - {/* Monthly / Annual Toggle */} -
- Monthly - - Annual - - Save 20% - -
-
- - {/* Comparison Table */} -
-
-

- Detailed Comparison -

-
- {features.map((section) => ( -
-

- {section.category} -

-
- - - - - - - - - - - {section.items.map((item) => ( - - - - - - - ))} - -
FeatureStarterProfessionalEnterprise
- {item.name} - - {typeof item.starter === "boolean" ? ( - item.starter ? ( - - ) : ( - - ) - ) : ( - item.starter - )} - - {typeof item.professional === "boolean" ? ( - item.professional ? ( - - ) : ( - - ) - ) : ( - item.professional - )} - - {typeof item.enterprise === "boolean" ? ( - item.enterprise ? ( - - ) : ( - - ) - ) : ( - item.enterprise - )} -
-
-
- ))} -
-
-
- - {/* FAQ Section */} -
-
-

- Frequently Asked Questions -

-

- Can't find the answer you're looking for? Contact our support team. -

-
-
- {faqs.map((faq) => ( - -
- - {faq.question} - - ▼ - - -

- {faq.answer} -

-
-
- ))} -
-
- - {/* CTA Section */} -
-
-

- Ready to get started? -

-

- Start with our free plan, no credit card required -

- - - -
-
- - ) -} diff --git a/package-lock.json b/package-lock.json index 6f2a5712e5..f3acd5d09f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -1033,6 +1034,93 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1202,6 +1290,92 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -6914,6 +7088,46 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" }, + "@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, "@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -6991,6 +7205,45 @@ } } }, + "@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "requires": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "dependencies": { + "@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "requires": { + "@radix-ui/react-slot": "1.2.3" + } + }, + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } + } + } + }, "@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -8837,6 +9090,7 @@ "@hey-api/openapi-ts": "0.73.0", "@hookform/resolvers": "^5.2.2", "@playwright/test": "1.58.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", From 0e80db59b64ce507e3e426b78180899bfe2ef0e9 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Tue, 31 Mar 2026 01:34:32 +0700 Subject: [PATCH 08/30] update --- frontend/src/components/Common/Logo.tsx | 20 +++++----- frontend/src/components/header.tsx | 50 ++++++++++++------------- frontend/src/components/hero.tsx | 35 ++++++++++++----- 3 files changed, 60 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/Common/Logo.tsx b/frontend/src/components/Common/Logo.tsx index 148c618038..448806f0d1 100644 --- a/frontend/src/components/Common/Logo.tsx +++ b/frontend/src/components/Common/Logo.tsx @@ -31,25 +31,25 @@ export function Logo({ src={fullLogo} alt="Logo" className={cn( - "h-8 w-auto object-contain group-data-[collapsible=icon]:hidden", - className, - )} - /> - + ) : ( Logo diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 57ae61efac..83aca889c3 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -2,38 +2,35 @@ import { Link } from '@tanstack/react-router' import { Appearance } from './Common/Appearance' import { isLoggedIn } from '@/hooks/useAuth' import { ArrowRight } from 'lucide-react' +import { Logo } from './Common/Logo' export default function Header() { - console.log("Header rendered, isLoggedIn:", isLoggedIn()) return ( -
+
-
- -
- {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - - -
- KeToanAuto - - -
-
+ + {/* Left — Logo */} + + + + + {/* Centre — Nav links */} + + + {/* Right — Actions */} +
- {!isLoggedIn() ? ( <> @@ -52,6 +49,7 @@ export default function Header() { )}
+
diff --git a/frontend/src/components/hero.tsx b/frontend/src/components/hero.tsx index b272a4873a..d56ecb175d 100644 --- a/frontend/src/components/hero.tsx +++ b/frontend/src/components/hero.tsx @@ -1,3 +1,5 @@ +import { isLoggedIn } from '@/hooks/useAuth' +import { useNavigate } from '@tanstack/react-router' import React, { useState, useRef } from 'react' interface HeroProps { @@ -7,6 +9,15 @@ interface HeroProps { export default function Hero({ onOpenDialog }: HeroProps) { const [isDrag, setIsDrag] = useState(false) const fileInputRef = useRef(null) + const navigate = useNavigate() + + const requireAuth = (action: () => void) => { + if (!isLoggedIn()) { + navigate({ to: '/login' }) + return + } + action() + } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() @@ -20,14 +31,15 @@ export default function Hero({ onOpenDialog }: HeroProps) { const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDrag(false) - const files = e.dataTransfer.files - if (files.length > 0) { - const file = files[0] - const isValidType = file.type === 'application/pdf' || file.type.startsWith('image/') - if (isValidType) { - console.log('[v0] File selected:', file.name) - } - } + requireAuth(() => { + navigate({ to: '/dashboard' }) + }) + } + + const handleClick = () => { + requireAuth(() => { + navigate({ to: '/dashboard' }) + }) } return ( @@ -42,11 +54,13 @@ export default function Hero({ onOpenDialog }: HeroProps) {

+ {/** biome-ignore lint/a11y/noStaticElementInteractions: */} + {/** biome-ignore lint/a11y/useKeyWithClickEvents: */}
fileInputRef.current?.click()} + onClick={handleClick} className={`cursor-pointer rounded-2xl border-2 border-dashed p-16 sm:p-20 text-center transition-all ${ isDrag ? 'border-primary bg-primary/10 shadow-2xl scale-105' @@ -54,6 +68,7 @@ export default function Hero({ onOpenDialog }: HeroProps) { }`} >
+ {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} @@ -79,6 +94,7 @@ export default function Hero({ onOpenDialog }: HeroProps) {
+ {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} @@ -88,6 +104,7 @@ export default function Hero({ onOpenDialog }: HeroProps) {
+ {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} From d5c62b7bfa5a9d5704c1bbcbb6372e982f79ebae Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Mon, 6 Apr 2026 00:48:07 +0700 Subject: [PATCH 09/30] update code --- .env | 12 +- .env.local | 59 ++++ backend/app/ocrs/service.py | 4 +- certs/local.crt | 17 + certs/local.key | 28 ++ compose.direct.yml | 125 +++++++ compose.traefik.yml | 2 + compose.yml | 7 +- .../8146c574-67c95621bc7aa55224d7639337aec22a | 9 + frontend/src/components/Admin/AddUser.tsx | 2 +- frontend/src/components/Admin/EditUser.tsx | 2 +- frontend/src/components/Common/Logo.tsx | 20 +- frontend/src/components/Files/columns.tsx | 4 +- frontend/src/components/Items/AddItem.tsx | 2 +- frontend/src/components/Items/EditItem.tsx | 2 +- frontend/src/components/faq.tsx | 48 ++- frontend/src/components/features.tsx | 50 +-- frontend/src/components/footer.tsx | 148 +++++++-- frontend/src/components/header.tsx | 51 ++- frontend/src/components/hero.tsx | 107 ++++-- frontend/src/components/how-it-works.tsx | 28 +- frontend/src/components/testimonials.tsx | 35 +- .../src/components/theme-provider copy.tsx | 11 - frontend/src/lib/mock-data.ts | 55 ++-- frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/_layout/dashboard.tsx | 10 +- frontend/src/routes/_layout/files.tsx | 2 +- frontend/src/routes/_layout/index.tsx | 9 +- frontend/src/routes/_public.tsx | 1 - frontend/src/routes/_public/home.tsx | 63 ++-- frontend/src/routes/_public/pricing.tsx | 308 ++++++++++++++++++ frontend/src/utils.ts | 2 +- frontend/vite.config.ts | 3 + test/test.yml | 13 + 34 files changed, 1030 insertions(+), 230 deletions(-) create mode 100644 .env.local create mode 100644 certs/local.crt create mode 100644 certs/local.key create mode 100644 compose.direct.yml create mode 100644 frontend/.tanstack/tmp/8146c574-67c95621bc7aa55224d7639337aec22a delete mode 100644 frontend/src/components/theme-provider copy.tsx create mode 100644 frontend/src/routes/_public/pricing.tsx create mode 100644 test/test.yml diff --git a/.env b/.env index f6e60cbc37..a37b7b783b 100644 --- a/.env +++ b/.env @@ -11,15 +11,15 @@ FRONTEND_HOST=http://localhost:5173 # FRONTEND_HOST=https://dashboard.example.com # Environment: local, staging, production -ENVIRONMENT=local +ENVIRONMENT=production -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project +PROJECT_NAME="KeToanAuto" +STACK_NAME=ketoanauto # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" SECRET_KEY=Ti100600@12131231 -FIRST_SUPERUSER=admin@example.com +FIRST_SUPERUSER=nguyenvantien0620@gmail.com FIRST_SUPERUSER_PASSWORD=Ti100600@ # Emails @@ -41,8 +41,8 @@ POSTGRES_PASSWORD=Ti100600@ SENTRY_DSN= # Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend +DOCKER_IMAGE_BACKEND=nguyentien0620/backend +DOCKER_IMAGE_FRONTEND=nguyentien0620/frontend # AWS S3 (Cloudflare R2) credentials diff --git a/.env.local b/.env.local new file mode 100644 index 0000000000..a7619e4dc2 --- /dev/null +++ b/.env.local @@ -0,0 +1,59 @@ +# Domain +# This would be set to the production domain with an env var on deployment +# used by Traefik to transmit traffic and aqcuire TLS certificates +DOMAIN=localhost +# To test the local Traefik config +# DOMAIN=localhost.tiangolo.com + +# Used by the backend to generate links in emails to the frontend +FRONTEND_HOST=http://localhost:5173 +# In staging and production, set this env var to the frontend host, e.g. +# FRONTEND_HOST=https://dashboard.example.com + +# Environment: local, staging, production +ENVIRONMENT=local + +PROJECT_NAME="KeToanAuto" +STACK_NAME=ketoanauto + +# Backend +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" +SECRET_KEY=Ti100600@12131231 +FIRST_SUPERUSER=nguyenvantien0620@gmail.com +FIRST_SUPERUSER_PASSWORD=Ti100600@ + +# Emails +SMTP_HOST= +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL=info@example.com +SMTP_TLS=True +SMTP_SSL=False +SMTP_PORT=587 + +# Postgres +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=KeToanAuto +POSTGRES_USER=postgres +POSTGRES_PASSWORD=Ti100600@ + +SENTRY_DSN= + +# Configure these with your own Docker registry images +DOCKER_IMAGE_BACKEND=backend +DOCKER_IMAGE_FRONTEND=frontend + + +# AWS S3 (Cloudflare R2) credentials +R2_ACCOUNT_ID="46252d78a71b1e948cca93580f21d6c8" +R2_ACCESS_KEY="fc94974446d24f787fc2c065211dd4b5" +R2_SECRET_KEY="7e2612409a61feb12a18d87cf960389e165b541680d660c86725de1e7cbbc753" +R2_BUCKET_NAME="ketoanauto" + +# OCR API +OCR_API_URL="https://nas3fbh253sfifna.aistudio-app.com/layout-parsing" +OCR_API_TOKEN="24f39b195ccd25b584dd4d3edac1179d1688b1c3" +OCR_JOB_URL="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" +OCR_JOB_POLLING_INTERVAL=5 # in seconds +OCR_MODEL="PaddleOCR-VL" \ No newline at end of file diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 1e8fb51d24..2ef613b522 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -1,6 +1,3 @@ -from sqlalchemy.testing.util import total_size -from app.storages.service import update_storage_stat, increment_storage_stat -from app.storages.schemas import UserStorageStatUpdate import logging import requests @@ -13,6 +10,7 @@ from app.ocrs.constants import OcrJobStatus from app.ocrs.dependencies import CurrentUser, SessionDep from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse +from app.storages.service import increment_storage_stat from app.utils import get_bytes_from_file_url logger = logging.getLogger(__name__) diff --git a/certs/local.crt b/certs/local.crt new file mode 100644 index 0000000000..26e0ce84a9 --- /dev/null +++ b/certs/local.crt @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICtjCCAZ4CCQDG2k3twe91MzANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBIq +LmRvY2tlci5sb2NhbGhvc3QwHhcNMjYwNDAxMTY0ODM4WhcNMjcwNDAxMTY0ODM4 +WjAdMRswGQYDVQQDDBIqLmRvY2tlci5sb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQC6QdjHBlXtMNegDTOJGyhrrYoN23zaX4YiCWzz7PAm +E7QO3fag+0SjzAU7xTdVo9Q1Qc0ReTpBiYn1/4iR6M+372V3Wpm7yulHcFAPukdq +6venXOov3BDcaVoITlqj7Rjb9nFQVsaK5f3bnel9d5KD+h4+nBQHkpvc3HqHPSMG +8IOEshBVSk/hRLI+0FOt5mo3zJJzOxb43C8pBpWn01hZkAEQyoIvkiPXmOQrL7wy +yY1kJFPFfFQo7D+P+h8k7DnLHWCMmIxEvL+qXQdnfW73XpfIW8CRGyQ/igdeTpRs +Es7J1ioFDlpeJDCFJHhasCsohuOz5cx6C57zf2kOt6mlAgMBAAEwDQYJKoZIhvcN +AQELBQADggEBAKTNSnq72a9kGmSKaDJRmo41inkNOcGsim0t3FHqawjj36ivL9FS +D9b4Dsu+YiveNA88iTOspXl25MuC5xswP9rVxBsLGRCHsqOdveJEJoV47Vk0Un1J +FPS+yBGjmJM4LEp2uEfU0XEsuCd5zGr0X2BUPVkKTeDcR6+VkntJd9zpMNc7aIXv +j4prlVzYPw9hhr2QeTbc6/kvCSu2pNJeWL0ZjwYXS2xl73ysdrGstcFck4FJZnTt +pJKHWs1FbMySgpSFvGVNfLFulkSnaN3Vz23MTlfeTVtSBiL/mJtf0fx7Z2BKaxVa +Bv2sZiF5wRjr7p3XUZyKIkq2CA+5S8o/dZU= +-----END CERTIFICATE----- diff --git a/certs/local.key b/certs/local.key new file mode 100644 index 0000000000..90881c666c --- /dev/null +++ b/certs/local.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC6QdjHBlXtMNeg +DTOJGyhrrYoN23zaX4YiCWzz7PAmE7QO3fag+0SjzAU7xTdVo9Q1Qc0ReTpBiYn1 +/4iR6M+372V3Wpm7yulHcFAPukdq6venXOov3BDcaVoITlqj7Rjb9nFQVsaK5f3b +nel9d5KD+h4+nBQHkpvc3HqHPSMG8IOEshBVSk/hRLI+0FOt5mo3zJJzOxb43C8p +BpWn01hZkAEQyoIvkiPXmOQrL7wyyY1kJFPFfFQo7D+P+h8k7DnLHWCMmIxEvL+q +XQdnfW73XpfIW8CRGyQ/igdeTpRsEs7J1ioFDlpeJDCFJHhasCsohuOz5cx6C57z +f2kOt6mlAgMBAAECggEAZpmBFVlLGgZusO60tdDs+iu1QZ7nbs9x7uvsRY3+V6tA +43OnuNPQ4r2vIFap/ZXqfo/Jq9dwnMtr4MOrclyhl7va091nlAfZaw3WPGOrlZzr +YRkQs95wt0mdW7f1vBkOOZTOpKe4ZKj+puycww2L+wFbibemXOmIzCfzou2tjtMa +xhc0HpKZNHB6EeUUapHefvqEFEqMe72rFkqqEPeoQF914NFfocCQXdN7pLI47+OH +zYdqP3x2ReGSifV1PdG1CrpbgKioJswlholfD2hhv6DTriCllVdYeliwjatWHUL9 +IcgbbtBixnuGGSd78My6koxN0VPlKbpVvlJI46pIoQKBgQDuz3m8uCyyBFuY52p7 +hnzM74DlbzjqFXx57CsbP4LR+3oB/ZPczqwiemyYJkSfne5SkC/VVXjch9IeAFRq +XQfwNm5blg+zyNZySvtPfuKcrx1ojeRlU8q8anzCFxLMZSzYY6YpKTSphRbEJMeS +irDtfwhUlg6b54hVsKsWY+4d7QKBgQDHqfsJJoRghg1wF7tZGJUyT/8gMUuvOtJY +hTX1pCjG7P168xTU935EX4kVCiyH3Rr/Bv0updv07bzqAThhJSJvkovfqcG/NSmh +jC168m4Yk9CGLmM4KpujOS26UsWO4bYSneMngaiQ1suZOwt2JSTD03CWNdxOXX0A +AQ7jRYYDmQKBgQCjS1F13wYI7/vmMQ9Zydtaksazm/rx7aFBCWFsb2A3z1pdNBTA +Xr3LkaTh4QD5mBdXc2qR2LEdMu5VP2p5lIWSFtYdYB36lHE2k9kGQcAY3ZEhZizv +sH0nmzUVzos3IlOo33LGIHv3Ep8/ndqtdJKIw11h4X2503chCP3kAI7Y/QKBgE2S +dtvJQSkXK+Ve8wTcjiqr9d0WCeecnNiTeLFlBAq1TI4WHwPW3BHIZEPuXfqzJqfq +mTckbV6tdvYbX0Iu4UAj2YAePg4Bo5kGEy1vPuMBmsRnBVlvBGTX9DItsl+exdRZ +z0UsFMehDB0OWZefOrdyUI2rg1pW7BeyUYxvGHARAoGAGaMr+vwQ//yafgnPGWfw +wBFtl0cqy3yxgD0wtW5eAE2WIsZdeFg9a+2AFYmILL/S8WSxYJftBugw7P2R6Tvi +MVN9phSxuAMryH7sFsxRU+6HMJV/vXGKmfgAewK2k3mtRAMH46ZxQMpxtqeNXYGY +NxSNH1ORcH5aSSqPKIJmMX4= +-----END PRIVATE KEY----- diff --git a/compose.direct.yml b/compose.direct.yml new file mode 100644 index 0000000000..cd5bd41eaf --- /dev/null +++ b/compose.direct.yml @@ -0,0 +1,125 @@ +services: + + db: + image: postgres:18 + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + volumes: + - app-db-data:/var/lib/postgresql/data/pgdata + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_DB=${POSTGRES_DB?Variable not set} + ports: + - "5432:5432" + + adminer: + image: adminer + restart: always + depends_on: + - db + environment: + - ADMINER_DESIGN=pepa-linha-dark + ports: + - "8080:8080" + + prestart: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + build: + context: . + dockerfile: backend/Dockerfile + depends_on: + db: + condition: service_healthy + restart: true + command: bash scripts/prestart.sh + env_file: + - .env + environment: + - DOMAIN=${DOMAIN} + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - SENTRY_DSN=${SENTRY_DSN} + + backend: + image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' + restart: always + build: + context: . + dockerfile: backend/Dockerfile + depends_on: + db: + condition: service_healthy + restart: true + prestart: + condition: service_completed_successfully + env_file: + - .env + environment: + - DOMAIN=${DOMAIN} + - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} + - ENVIRONMENT=${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS} + - SECRET_KEY=${SECRET_KEY?Variable not set} + - FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set} + - SMTP_HOST=${SMTP_HOST} + - SMTP_USER=${SMTP_USER} + - SMTP_PASSWORD=${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - SENTRY_DSN=${SENTRY_DSN} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "8000:8000" + + frontend: + image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' + restart: always + build: + context: . + dockerfile: frontend/Dockerfile + args: + - VITE_API_URL=http://${DOMAIN?Variable not set}:8000 + - NODE_ENV=production + depends_on: + backend: + condition: service_healthy + ports: + - "80:80" + +volumes: + app-db-data: + +networks: + traefik-public: + external: true \ No newline at end of file diff --git a/compose.traefik.yml b/compose.traefik.yml index bcd7d142ca..2547e6045b 100644 --- a/compose.traefik.yml +++ b/compose.traefik.yml @@ -6,6 +6,8 @@ services: - 80:80 # Listen on port 443, default for HTTPS - 443:443 + + - 8080:8080 # Port for the Traefik Dashboard (optional) restart: always labels: # Enable Traefik for this service, to make it available in the public network diff --git a/compose.yml b/compose.yml index 2488fc007b..0805b5573c 100644 --- a/compose.yml +++ b/compose.yml @@ -18,7 +18,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_DB=${POSTGRES_DB?Variable not set} - + # platform: linux/amd64 adminer: image: adminer restart: always @@ -41,6 +41,7 @@ services: - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls=true - traefik.http.routers.${STACK_NAME?Variable not set}-adminer-https.tls.certresolver=le - traefik.http.services.${STACK_NAME?Variable not set}-adminer.loadbalancer.server.port=8080 + # platform: linux/amd64 prestart: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' @@ -75,7 +76,7 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} - + # platform: linux/amd64 backend: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' restart: always @@ -108,6 +109,7 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} + # platform: linux/amd64 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] @@ -165,6 +167,7 @@ services: # Enable redirection for HTTP and HTTPS - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect + # platform: linux/amd64 volumes: app-db-data: diff --git a/frontend/.tanstack/tmp/8146c574-67c95621bc7aa55224d7639337aec22a b/frontend/.tanstack/tmp/8146c574-67c95621bc7aa55224d7639337aec22a new file mode 100644 index 0000000000..f3d3a0291c --- /dev/null +++ b/frontend/.tanstack/tmp/8146c574-67c95621bc7aa55224d7639337aec22a @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_public/pricing')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/_public/pricing"!
+} diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx index a0b534bd96..b4f6f6016d 100644 --- a/frontend/src/components/Admin/AddUser.tsx +++ b/frontend/src/components/Admin/AddUser.tsx @@ -73,7 +73,7 @@ const AddUser = () => { const mutation = useMutation({ mutationFn: (data: UserCreate) => - UsersService.createUser({ requestBody: data }), + UsersService.createUserEndpoint({ requestBody: data }), onSuccess: () => { showSuccessToast("User created successfully") form.reset() diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx index 172904f695..55ee4b0779 100644 --- a/frontend/src/components/Admin/EditUser.tsx +++ b/frontend/src/components/Admin/EditUser.tsx @@ -75,7 +75,7 @@ const EditUser = ({ user, onSuccess }: EditUserProps) => { const mutation = useMutation({ mutationFn: (data: FormData) => - UsersService.updateUser({ userId: user.id, requestBody: data }), + UsersService.updateUserEndpoint({ userId: user.id, requestBody: data }), onSuccess: () => { showSuccessToast("User updated successfully") setIsOpen(false) diff --git a/frontend/src/components/Common/Logo.tsx b/frontend/src/components/Common/Logo.tsx index 448806f0d1..239593646a 100644 --- a/frontend/src/components/Common/Logo.tsx +++ b/frontend/src/components/Common/Logo.tsx @@ -35,21 +35,23 @@ export function Logo({ className, )} /> - + ) : ( Logo diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index c250a8afa1..c53b74a6a2 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -1,14 +1,14 @@ import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" -import { Check, DownloadIcon, Loader2, RefreshCcw } from "lucide-react" +import { DownloadIcon, Loader2, RefreshCcw } from "lucide-react" import { useState } from "react" +import { FaFileExcel } from "react-icons/fa6" import { type FilePublic, FilesService } from "@/client" import { OpenAPI } from "@/client/core/OpenAPI" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" -import { FaFileExcel } from "react-icons/fa6" async function downloadExcel(fileId: string, filename: string) { const token = diff --git a/frontend/src/components/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx index 7c7c10cf51..fae9c2bf2a 100644 --- a/frontend/src/components/Items/AddItem.tsx +++ b/frontend/src/components/Items/AddItem.tsx @@ -54,7 +54,7 @@ const AddItem = () => { const mutation = useMutation({ mutationFn: (data: ItemCreate) => - ItemsService.createItem({ requestBody: data }), + ItemsService.createItemEndpoint({ requestBody: data }), onSuccess: () => { showSuccessToast("Item created successfully") form.reset() diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx index 3d57f559f5..5a48715875 100644 --- a/frontend/src/components/Items/EditItem.tsx +++ b/frontend/src/components/Items/EditItem.tsx @@ -59,7 +59,7 @@ const EditItem = ({ item, onSuccess }: EditItemProps) => { const mutation = useMutation({ mutationFn: (data: FormData) => - ItemsService.updateItem({ id: item.id, requestBody: data }), + ItemsService.updateItemEndpoint({ id: item.id, requestBody: data }), onSuccess: () => { showSuccessToast("Item updated successfully") setIsOpen(false) diff --git a/frontend/src/components/faq.tsx b/frontend/src/components/faq.tsx index 928bceeced..cbdd9d554f 100644 --- a/frontend/src/components/faq.tsx +++ b/frontend/src/components/faq.tsx @@ -1,33 +1,38 @@ -'use client' +"use client" -import React from 'react' -import * as Accordion from '@radix-ui/react-accordion' +import * as Accordion from "@radix-ui/react-accordion" const faqs = [ { - question: 'What is PDF Guru?', - answer: 'PDF Guru is an all-in-one online PDF converter that lets you transform PDF files into various formats, including Excel (XLSX). No installation or technical skills required.' + question: "What is KeToanAuto?", + answer: + "KeToanAuto is an all-in-one online PDF/Images converter that lets you transform PDF/Images files into various formats, including Excel (XLSX). No installation or technical skills required.", }, { - question: 'Is PDF Guru safe to use?', - answer: 'Yes, absolutely. We use advanced encryption to ensure your sensitive data stays secure throughout the entire process. Your privacy is our priority.' + question: "Is KeToanAuto safe to use?", + answer: + "Yes, absolutely. We use advanced encryption to ensure your sensitive data stays secure throughout the entire process. Your privacy is our priority.", }, { - question: 'Is PDF Guru available as a subscription or one-time purchase?', - answer: 'Your first file is always free to convert. After that, we offer flexible subscription plans to meet your needs.' + question: "Is KeToanAuto available as a subscription or one-time purchase?", + answer: + "Your first file is always free to convert. After that, we offer flexible subscription plans to meet your needs.", }, + // { + // question: "How to edit PDF files using KeToanAuto?", + // answer: + // "KeToanAuto provides intuitive editing tools. Simply upload your PDF, use our editor to make changes, and download your modified file.", + // }, { - question: 'How to edit PDF files using PDF Guru?', - answer: 'PDF Guru provides intuitive editing tools. Simply upload your PDF, use our editor to make changes, and download your modified file.' + question: "What file types does KeToanAuto support?", + answer: + "KeToanAuto supports conversion to and from PDF, Excel, Word, PowerPoint, and many other popular document formats.", }, { - question: 'What file types does PDF Guru support?', - answer: 'PDF Guru supports conversion to and from PDF, Excel, Word, PowerPoint, and many other popular document formats.' + question: "What should I do if I encounter problems?", + answer: + "Our friendly support team is here to help. Contact us directly through the support form, and we'll get back to you quickly with a solution.", }, - { - question: 'What should I do if I encounter problems?', - answer: 'Our friendly support team is here to help. Contact us directly through the support form, and we'll get back to you quickly with a solution.' - } ] export default function FAQ() { @@ -46,6 +51,7 @@ export default function FAQ() { {faqs.map((faq, index) => ( key={index} value={`item-${index}`} className="rounded-lg border border-border bg-background overflow-hidden" @@ -53,13 +59,19 @@ export default function FAQ() { {faq.question} + {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - + diff --git a/frontend/src/components/features.tsx b/frontend/src/components/features.tsx index 66e6310fe2..bb547bac43 100644 --- a/frontend/src/components/features.tsx +++ b/frontend/src/components/features.tsx @@ -1,36 +1,40 @@ -import React from 'react' - const features = [ { - icon: '📄', - title: 'Multiple File Types', - description: 'Convert bank statements from PDF and image formats. Supports JPG, PNG, and PDF uploads.' + icon: "📄", + title: "Multiple File Types", + description: + "Convert bank statements from PDF and image formats. Supports JPG, PNG, and PDF uploads.", }, { - icon: '🔒', - title: 'Bank-Grade Security', - description: 'Your financial data is encrypted and never stored on our servers. Complete privacy guaranteed.' + icon: "🔒", + title: "Bank-Grade Security", + description: + "Your financial data is encrypted and never stored on our servers. Complete privacy guaranteed.", }, { - icon: '⚡', - title: 'Instant Conversion', - description: 'Process your bank statements in seconds. Get organized Excel files ready to use immediately.' + icon: "⚡", + title: "Instant Conversion", + description: + "Process your bank statements in seconds. Get organized Excel files ready to use immediately.", }, { - icon: '✅', - title: 'Accurate Data Extraction', - description: 'Preserves all transaction details, amounts, and dates with 99.9% accuracy. No data loss.' + icon: "✅", + title: "Accurate Data Extraction", + description: + "Preserves all transaction details, amounts, and dates with 99.9% accuracy. No data loss.", }, { - icon: '📊', - title: 'Ready for Analysis', - description: 'Converted Excel files are fully formatted and compatible with all spreadsheet applications.' + icon: "📊", + title: "Ready for Analysis", + description: + "Converted Excel files are fully formatted and compatible with all spreadsheet applications.", }, { - icon: '🚀', - title: 'Zero Setup Required', - description: 'No installation, no registration required. Start converting your bank statements instantly.' - } + icon: "🚀", + title: "Zero Setup Required", + description: + "No installation, no registration required. Start converting your bank statements instantly.", + }, ] export default function Features() { @@ -56,9 +60,7 @@ export default function Features() {

{feature.title}

-

- {feature.description} -

+

{feature.description}

))}
diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index 8dca4edc09..4fcfd4f4f7 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -1,5 +1,3 @@ -import React from 'react' - export default function Footer() { return (
diff --git a/frontend/src/components/hero.tsx b/frontend/src/components/hero.tsx index d56ecb175d..52de06bc6d 100644 --- a/frontend/src/components/hero.tsx +++ b/frontend/src/components/hero.tsx @@ -1,19 +1,16 @@ -import { isLoggedIn } from '@/hooks/useAuth' -import { useNavigate } from '@tanstack/react-router' -import React, { useState, useRef } from 'react' +import { useNavigate } from "@tanstack/react-router" +import type React from "react" +import { useRef, useState } from "react" +import { isLoggedIn } from "@/hooks/useAuth" -interface HeroProps { - onOpenDialog: () => void -} - -export default function Hero({ onOpenDialog }: HeroProps) { +export default function Hero() { const [isDrag, setIsDrag] = useState(false) const fileInputRef = useRef(null) const navigate = useNavigate() const requireAuth = (action: () => void) => { if (!isLoggedIn()) { - navigate({ to: '/login' }) + navigate({ to: "/login" }) return } action() @@ -32,13 +29,13 @@ export default function Hero({ onOpenDialog }: HeroProps) { e.preventDefault() setIsDrag(false) requireAuth(() => { - navigate({ to: '/dashboard' }) + navigate({ to: "/dashboard" }) }) } const handleClick = () => { requireAuth(() => { - navigate({ to: '/dashboard' }) + navigate({ to: "/dashboard" }) }) } @@ -50,7 +47,8 @@ export default function Hero({ onOpenDialog }: HeroProps) { Convert Bank Statements to Excel

- Upload your PDF or image bank statements and get organized Excel files instantly + Upload your PDF or image bank statements and get organized Excel + files instantly

@@ -63,14 +61,24 @@ export default function Hero({ onOpenDialog }: HeroProps) { onClick={handleClick} className={`cursor-pointer rounded-2xl border-2 border-dashed p-16 sm:p-20 text-center transition-all ${ isDrag - ? 'border-primary bg-primary/10 shadow-2xl scale-105' - : 'border-primary/40 bg-primary/5 hover:bg-primary/8 hover:border-primary/60 shadow-lg hover:shadow-2xl' + ? "border-primary bg-primary/10 shadow-2xl scale-105" + : "border-primary/40 bg-primary/5 hover:bg-primary/8 hover:border-primary/60 shadow-lg hover:shadow-2xl" }`} >
{/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - + +

@@ -95,31 +103,74 @@ export default function Hero({ onOpenDialog }: HeroProps) {

{/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - + +
-

Instant Conversion

-

PDFs & images to Excel

+

+ Instant Conversion +

+

+ PDFs & images to Excel +

{/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - + +
-

Bank-Grade Security

-

Your data is encrypted

+

+ Bank-Grade Security +

+

+ Your data is encrypted +

- - + {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + +
-

100% Accurate

-

Preserves all data

+

+ 100% Accurate +

+

+ Preserves all data +

diff --git a/frontend/src/components/how-it-works.tsx b/frontend/src/components/how-it-works.tsx index 3a72608ee0..a4a97186a4 100644 --- a/frontend/src/components/how-it-works.tsx +++ b/frontend/src/components/how-it-works.tsx @@ -1,21 +1,22 @@ -import React from 'react' - const steps = [ { - number: '1', - title: 'Upload Your Bank Statement', - description: 'Drag and drop your PDF or image bank statement. Supports JPG, PNG, and PDF formats.' + number: "1", + title: "Upload Your Bank Statement", + description: + "Drag and drop your PDF or image bank statement. Supports JPG, PNG, and PDF formats.", }, { - number: '2', - title: 'We Extract the Data', - description: 'Our AI-powered system analyzes and extracts all transaction details automatically in seconds.' + number: "2", + title: "We Extract the Data", + description: + "Our AI-powered system analyzes and extracts all transaction details automatically in seconds.", }, { - number: '3', - title: 'Download as Excel', - description: 'Your bank statement is now organized in an Excel file, ready for analysis and archiving.' - } + number: "3", + title: "Download as Excel", + description: + "Your bank statement is now organized in an Excel file, ready for analysis and archiving.", + }, ] export default function HowItWorks() { @@ -54,7 +55,8 @@ export default function HowItWorks() {

- Convert your first bank statement for free. Unlock unlimited conversions with KeToanAuto Pro. + Convert your first bank statement for free. Unlock unlimited + conversions with KeToanAuto Pro.

diff --git a/frontend/src/components/testimonials.tsx b/frontend/src/components/testimonials.tsx index 2e35f92037..10d7f1d526 100644 --- a/frontend/src/components/testimonials.tsx +++ b/frontend/src/components/testimonials.tsx @@ -1,24 +1,25 @@ -import React from 'react' - const testimonials = [ { - name: 'Sarah Martinez', - role: 'Business Manager', - content: 'Easy to use, good service. Exactly what I needed for converting my documents.', - rating: 5 + name: "Sarah Martinez", + role: "Business Manager", + content: + "Easy to use, good service. Exactly what I needed for converting my documents.", + rating: 5, }, { - name: 'James Chen', - role: 'Freelance Designer', - content: 'Great experience, high quality service! The conversion was perfect and very fast.', - rating: 5 + name: "James Chen", + role: "Freelance Designer", + content: + "Great experience, high quality service! The conversion was perfect and very fast.", + rating: 5, }, { - name: 'Emily Rodriguez', - role: 'Data Analyst', - content: 'Good Service, Very good customer service! They helped me when I had questions.', - rating: 5 - } + name: "Emily Rodriguez", + role: "Data Analyst", + content: + "Good Service, Very good customer service! They helped me when I had questions.", + rating: 5, + }, ] export default function Testimonials() { @@ -42,7 +43,9 @@ export default function Testimonials() { >
{Array.from({ length: testimonial.rating }).map((_, i) => ( - + + ★ + ))}

diff --git a/frontend/src/components/theme-provider copy.tsx b/frontend/src/components/theme-provider copy.tsx deleted file mode 100644 index 55c2f6eb60..0000000000 --- a/frontend/src/components/theme-provider copy.tsx +++ /dev/null @@ -1,11 +0,0 @@ -'use client' - -import * as React from 'react' -import { - ThemeProvider as NextThemesProvider, - type ThemeProviderProps, -} from 'next-themes' - -export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return {children} -} diff --git a/frontend/src/lib/mock-data.ts b/frontend/src/lib/mock-data.ts index 174b29c795..a0c65e7ec4 100644 --- a/frontend/src/lib/mock-data.ts +++ b/frontend/src/lib/mock-data.ts @@ -53,44 +53,49 @@ export const mockFileHistory: FileHistoryItem[] = [ export const pricingTiers: PricingTier[] = [ { - name: "Starter", - price: "Free", - description: "Perfect for individuals getting started", + name: "Free", + price: "$0", + description: "Get started at no cost — no credit card required", features: [ - "5 files per month", - "Basic format conversion", + "5 conversions per month", + "PDF & image bank statements", + "Basic Excel export", + "Up to 5 MB file size", "Email support", - "Up to 2 MB file size", ], - cta: "Get Started", + cta: "Get Started Free", }, { - name: "Professional", - price: "99,000", - description: "For active accountants and small firms", + name: "Pro", + price: "$4.99", + description: "For individuals and freelancers who convert regularly", features: [ - "Unlimited files", - "Advanced formatting", + "100 conversions per month", + "PDF & image bank statements", + "Advanced Excel formatting", + "Up to 50 MB file size", "Transaction categorization", - "Priority support", - "API access", - "Custom templates", + "Priority email support", + "Download history (30 days)", ], cta: "Start Free Trial", highlighted: true, }, { - name: "Enterprise", - price: "Custom", - description: "For large accounting firms", + name: "Business", + price: "$9.99", + description: "For teams and accounting firms with high volume needs", features: [ - "Everything in Professional", - "Dedicated account manager", - "Custom integration", - "Team management", - "Advanced analytics", - "99.9% SLA", + "Unlimited conversions", + "All file types supported", + "Custom Excel templates", + "Up to 100 MB file size", + "Batch processing", + "API access", + "Team management (up to 5 seats)", + "Priority support & SLA", + "Download history (unlimited)", ], - cta: "Contact Sales", + cta: "Start Free Trial", }, ] diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 5e7a77332d..2b42ebd6db 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as PublicRouteImport } from './routes/_public' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' +import { Route as PublicPricingRouteImport } from './routes/_public/pricing' import { Route as PublicHomeRouteImport } from './routes/_public/home' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' @@ -56,6 +57,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({ path: '/', getParentRoute: () => LayoutRoute, } as any) +const PublicPricingRoute = PublicPricingRouteImport.update({ + id: '/pricing', + path: '/pricing', + getParentRoute: () => PublicRoute, +} as any) const PublicHomeRoute = PublicHomeRouteImport.update({ id: '/home', path: '/home', @@ -99,6 +105,7 @@ export interface FileRoutesByFullPath { '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute '/home': typeof PublicHomeRoute + '/pricing': typeof PublicPricingRoute } export interface FileRoutesByTo { '/': typeof LayoutIndexRoute @@ -112,6 +119,7 @@ export interface FileRoutesByTo { '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute '/home': typeof PublicHomeRoute + '/pricing': typeof PublicPricingRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -127,6 +135,7 @@ export interface FileRoutesById { '/_layout/items': typeof LayoutItemsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_public/home': typeof PublicHomeRoute + '/_public/pricing': typeof PublicPricingRoute '/_layout/': typeof LayoutIndexRoute } export interface FileRouteTypes { @@ -143,6 +152,7 @@ export interface FileRouteTypes { | '/items' | '/settings' | '/home' + | '/pricing' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -156,6 +166,7 @@ export interface FileRouteTypes { | '/items' | '/settings' | '/home' + | '/pricing' id: | '__root__' | '/_layout' @@ -170,6 +181,7 @@ export interface FileRouteTypes { | '/_layout/items' | '/_layout/settings' | '/_public/home' + | '/_public/pricing' | '/_layout/' fileRoutesById: FileRoutesById } @@ -233,6 +245,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutIndexRouteImport parentRoute: typeof LayoutRoute } + '/_public/pricing': { + id: '/_public/pricing' + path: '/pricing' + fullPath: '/pricing' + preLoaderRoute: typeof PublicPricingRouteImport + parentRoute: typeof PublicRoute + } '/_public/home': { id: '/_public/home' path: '/home' @@ -301,10 +320,12 @@ const LayoutRouteWithChildren = interface PublicRouteChildren { PublicHomeRoute: typeof PublicHomeRoute + PublicPricingRoute: typeof PublicPricingRoute } const PublicRouteChildren: PublicRouteChildren = { PublicHomeRoute: PublicHomeRoute, + PublicPricingRoute: PublicPricingRoute, } const PublicRouteWithChildren = diff --git a/frontend/src/routes/_layout/dashboard.tsx b/frontend/src/routes/_layout/dashboard.tsx index 80de88834b..afabe9effb 100644 --- a/frontend/src/routes/_layout/dashboard.tsx +++ b/frontend/src/routes/_layout/dashboard.tsx @@ -3,13 +3,12 @@ import { createFileRoute } from "@tanstack/react-router" import { ArrowRight } from "lucide-react" import { useState } from "react" import { FilesService, StoragesService } from "@/client" -import { FileHistoryTable } from "@/components/FileHistoryTable" import { FileUploadDropzone } from "@/components/FileUploadDropzone" import { useLoadingSpinner } from "@/components/loading-spinner-provider" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" import useCustomToast from "@/hooks/useCustomToast" -import { handleError, formatBytes } from "@/utils" +import { formatBytes, handleError } from "@/utils" import { FilesTableContent } from "./files" export const Route = createFileRoute("/_layout/dashboard")({ @@ -122,8 +121,7 @@ function Dashboard() {

Recent Conversions

{/* */} - - +
@@ -166,9 +164,7 @@ function Dashboard() {
Storage Used - {storageStat - ? formatBytes(storageStat.total_size) - : "—"} + {storageStat ? formatBytes(storageStat.total_size) : "—"}
diff --git a/frontend/src/routes/_layout/files.tsx b/frontend/src/routes/_layout/files.tsx index e5d1b0f302..9f9c7a4e0c 100644 --- a/frontend/src/routes/_layout/files.tsx +++ b/frontend/src/routes/_layout/files.tsx @@ -39,7 +39,7 @@ export function FilesTableContent({ limit = 0 }: { limit?: number }) { ) if (pendingFiles.length === 0) { - console.log('No pending files, stopping polling.') + console.log("No pending files, stopping polling.") if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index a13098bf1d..aae570d70b 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -111,7 +111,6 @@ function Home() {
{features.map((feature) => { - const _Icon = feature.icon return ( {/* */} @@ -229,9 +228,9 @@ function Home() { Start for Free - + {/* - + */}
@@ -248,11 +247,11 @@ function Home() { Features -
  • + {/*
  • Pricing -
  • + */}
  • Security diff --git a/frontend/src/routes/_public.tsx b/frontend/src/routes/_public.tsx index eec3520062..11c2325b49 100644 --- a/frontend/src/routes/_public.tsx +++ b/frontend/src/routes/_public.tsx @@ -1,5 +1,4 @@ import { createFileRoute, Outlet } from "@tanstack/react-router" -import { Navbar } from "@/components/Navbar" export const Route = createFileRoute("/_public")({ component: PublicLayout, diff --git a/frontend/src/routes/_public/home.tsx b/frontend/src/routes/_public/home.tsx index d7c01a22b4..282326233c 100644 --- a/frontend/src/routes/_public/home.tsx +++ b/frontend/src/routes/_public/home.tsx @@ -1,15 +1,15 @@ /** biome-ignore-all lint/a11y/noStaticElementInteractions: */ + +import * as Dialog from "@radix-ui/react-dialog" import { createFileRoute } from "@tanstack/react-router" import { useRef, useState } from "react" +import FAQ from "@/components/faq" +import Features from "@/components/features" +import Footer from "@/components/footer" import Header from "@/components/header" import Hero from "@/components/hero" -import Features from "@/components/features" import HowItWorks from "@/components/how-it-works" import Testimonials from "@/components/testimonials" -import FAQ from "@/components/faq" -import Footer from "@/components/footer" -import * as Dialog from '@radix-ui/react-dialog' -import useAuth from "@/hooks/useAuth" export const Route = createFileRoute("/_public/home")({ component: Home, @@ -23,7 +23,7 @@ function Home() { return (
    - setIsOpen(true)} /> + @@ -38,7 +38,8 @@ function Home() { Upload Bank Statement - Upload a PDF or image of your bank statement to convert to Excel (up to 100 MB) + Upload a PDF or image of your bank statement to convert to Excel + (up to 100 MB) setFileName(name)} /> @@ -47,7 +48,8 @@ function Home() {

    - Selected file: {fileName} + Selected file:{" "} + {fileName}

    {/** biome-ignore lint/a11y/useButtonType: */} @@ -64,8 +66,18 @@ function Home() { aria-label="Close" > {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - + + @@ -76,7 +88,11 @@ function Home() { ) } -function FileUploadZone({ onFileSelect }: { onFileSelect: (name: string) => void }) { +function FileUploadZone({ + onFileSelect, +}: { + onFileSelect: (name: string) => void +}) { const [isDrag, setIsDrag] = useState(false) const fileInputRef = useRef(null) @@ -95,7 +111,8 @@ function FileUploadZone({ onFileSelect }: { onFileSelect: (name: string) => void const files = e.dataTransfer.files if (files.length > 0) { const file = files[0] - const isValidType = file.type === 'application/pdf' || file.type.startsWith('image/') + const isValidType = + file.type === "application/pdf" || file.type.startsWith("image/") if (isValidType) { onFileSelect(file.name) } @@ -119,13 +136,23 @@ function FileUploadZone({ onFileSelect }: { onFileSelect: (name: string) => void onClick={() => fileInputRef.current?.click()} className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-all ${ isDrag - ? 'border-primary bg-secondary' - : 'border-border bg-muted hover:bg-secondary' + ? "border-primary bg-secondary" + : "border-border bg-muted hover:bg-secondary" }`} > {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - + +

    Drag and drop your bank statement here @@ -133,9 +160,7 @@ function FileUploadZone({ onFileSelect }: { onFileSelect: (name: string) => void

    PDF or image (JPG, PNG)

    -

    - Size up to 100 MB -

    +

    Size up to 100 MB

    ({ + meta: [{ title: "Pricing – BankToExcel" }], + }), +}) + +const ANNUAL_DISCOUNT = 0.2 // 20% off when billed annually + +function getAnnualPrice(monthlyPrice: string): string { + if (monthlyPrice === "$0") return "$0" + const num = Number.parseFloat(monthlyPrice.replace("$", "")) + if (Number.isNaN(num)) return monthlyPrice + const annual = (num * (1 - ANNUAL_DISCOUNT)).toFixed(2) + return `$${annual}` +} + +const faqs = [ + { + q: "Can I change my plan later?", + a: "Yes, you can upgrade or downgrade at any time. Changes take effect immediately and we'll prorate any billing differences.", + }, + { + q: "Is there a free trial?", + a: "Every paid plan includes a 7-day free trial. No credit card required to start.", + }, + { + q: "What file formats do you support?", + a: "We support PDF, JPG, PNG, and other common image formats. Scanned documents and digital PDFs both work.", + }, + { + q: "How accurate are the conversions?", + a: "Our AI achieves 99%+ accuracy on clearly formatted bank statements from most major banks.", + }, + { + q: "Can I cancel anytime?", + a: "Absolutely. Cancel with one click from your account settings — no cancellation fees.", + }, + { + q: "Do you offer team plans?", + a: "The Business plan includes up to 5 seats. Need more? Contact us for a custom quote.", + }, +] + +function PricingPage() { + const [annual, setAnnual] = useState(false) + + return ( +
    +
    + + {/* Hero */} +
    + + + Simple, transparent pricing + +

    + Plans for every workflow +

    +

    + Convert bank statements to Excel in seconds. Start free, upgrade when + you need more. +

    + + {/* Billing toggle */} +
    + + +
    +
    + + {/* Pricing cards */} +
    +
    + {pricingTiers.map((tier) => { + const displayPrice = annual + ? getAnnualPrice(tier.price) + : tier.price + const isFree = tier.price === "$0" + + return ( + + {tier.highlighted && ( +
    + + Most Popular + +
    + )} + +
    +

    {tier.name}

    +

    + {tier.description} +

    + +
    + + {displayPrice} + + {!isFree && ( + + /mo{annual ? ", billed annually" : ""} + + )} +
    + + {annual && !isFree && ( +

    + {tier.price}/mo +

    + )} + +
      + {tier.features.map((feature) => ( +
    • + + + {feature} + +
    • + ))} +
    +
    + +
    + + + + {!isFree && ( +

    + 7-day free trial · No credit card required +

    + )} +
    +
    + ) + })} +
    +
    + + {/* Feature comparison table */} +
    +
    +

    + Compare plans +

    +
    + + + + + {pricingTiers.map((t) => ( + + ))} + + + + {[ + { label: "Conversions / month", values: ["5", "100", "Unlimited"] }, + { label: "Max file size", values: ["5 MB", "50 MB", "100 MB"] }, + { label: "PDF support", values: [true, true, true] }, + { label: "Image support", values: [true, true, true] }, + { label: "Advanced formatting", values: [false, true, true] }, + { label: "Transaction categorization", values: [false, true, true] }, + { label: "Custom templates", values: [false, false, true] }, + { label: "Batch processing", values: [false, false, true] }, + { label: "API access", values: [false, false, true] }, + { label: "Team seats", values: ["—", "1", "Up to 5"] }, + { label: "Download history", values: ["—", "30 days", "Unlimited"] }, + { label: "Support", values: ["Email", "Priority email", "Priority + SLA"] }, + ].map((row) => ( + + + {row.values.map((val, i) => ( + + ))} + + ))} + +
    + Feature + + {t.name} +
    {row.label} + {typeof val === "boolean" ? ( + val ? ( + + ) : ( + + ) + ) : ( + {val} + )} +
    +
    +
    +
    + + {/* FAQ */} +
    +
    +

    + Frequently asked questions +

    +
    + {faqs.map((item) => ( +
    +
    + +
    +

    {item.q}

    +

    + {item.a} +

    +
    +
    +
    + ))} +
    +
    +
    + + {/* CTA banner */} +
    +
    +

    Ready to save hours every month?

    +

    + Join thousands of accountants who use BankToExcel to convert + statements in seconds — not hours. +

    +
    + + + + + + +
    +
    +
    + +
    +
    + ) +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 1444f4ee4f..e5d77c513c 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -37,5 +37,5 @@ export function formatBytes(bytes: number, decimals = 1): string { const k = 1024 const units = ["B", "KB", "MB", "GB", "TB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${units[i]}` + return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${units[i]}` } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 874db9071f..3d59d67387 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,4 +19,7 @@ export default defineConfig({ react(), tailwindcss(), ], + server: { + allowedHosts: true, + }, }) diff --git a/test/test.yml b/test/test.yml new file mode 100644 index 0000000000..e634f34efb --- /dev/null +++ b/test/test.yml @@ -0,0 +1,13 @@ +# docker-compose.yml +services: + traefik: + image: traefik:v3.6 + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--entrypoints.web.address=:80" + ports: + - "80:80" + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock \ No newline at end of file From 4854374119065e26b64e78c7bc88e3d152a988d6 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Tue, 7 Apr 2026 12:47:19 +0700 Subject: [PATCH 10/30] update --- .env | 2 +- frontend/package.json | 1 + .../UserSettings/ChangePassword.tsx | 163 +++++---- .../components/UserSettings/DeleteAccount.tsx | 36 +- .../UserSettings/DeleteConfirmation.tsx | 4 +- .../components/UserSettings/MonthlyUsage.tsx | 70 ++++ .../UserSettings/PaymentMethods.tsx | 36 ++ .../UserSettings/SubscriptionPlan.tsx | 44 +++ frontend/src/components/ui/progress.tsx | 28 ++ frontend/src/routes/_layout/settings.tsx | 40 +-- package-lock.json | 335 +++++++++++------- 11 files changed, 529 insertions(+), 230 deletions(-) create mode 100644 frontend/src/components/UserSettings/MonthlyUsage.tsx create mode 100644 frontend/src/components/UserSettings/PaymentMethods.tsx create mode 100644 frontend/src/components/UserSettings/SubscriptionPlan.tsx create mode 100644 frontend/src/components/ui/progress.tsx diff --git a/.env b/.env index a37b7b783b..668164beda 100644 --- a/.env +++ b/.env @@ -32,7 +32,7 @@ SMTP_SSL=False SMTP_PORT=587 # Postgres -POSTGRES_SERVER=localhost +POSTGRES_SERVER=157.66.25.86 POSTGRES_PORT=5432 POSTGRES_DB=KeToanAuto POSTGRES_USER=postgres diff --git a/frontend/package.json b/frontend/package.json index 492fa79034..6d7fe5d343 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx index aeb8537028..aff2deccb1 100644 --- a/frontend/src/components/UserSettings/ChangePassword.tsx +++ b/frontend/src/components/UserSettings/ChangePassword.tsx @@ -1,9 +1,12 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useMutation } from "@tanstack/react-query" +import { Info, Lock } from "lucide-react" import { useForm } from "react-hook-form" import { z } from "zod" import { type UpdatePassword, UsersService } from "@/client" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Form, FormControl, @@ -66,80 +69,98 @@ const ChangePassword = () => { } return ( -
    -

    Change Password

    -
    - - ( - - Current Password - - - - - - )} - /> + + + +
    + Change Password +

    + Update your account password +

    +
    +
    + + + + ( + + Current Password + + + + + + )} + /> - ( - - New Password - - - - - - )} - /> + ( + + New Password + + + + + + )} + /> - ( - - Confirm Password - - - - - - )} - /> + ( + + Confirm New Password + + + + + + )} + /> - - Update Password - - - -
    + + + + Password must be at least 8 characters and include uppercase, + lowercase, numbers, and special characters. + + + + + Update Password + + + + + ) } diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx index 7b9e895ec5..40c0824bff 100644 --- a/frontend/src/components/UserSettings/DeleteAccount.tsx +++ b/frontend/src/components/UserSettings/DeleteAccount.tsx @@ -1,14 +1,36 @@ +import { AlertCircle, Trash2 } from "lucide-react" + +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import DeleteConfirmation from "./DeleteConfirmation" const DeleteAccount = () => { return ( -
    -

    Delete Account

    -

    - Permanently delete your account and all associated data. -

    - -
    + + + +
    + + Delete Account + +

    + Permanently delete your account and all associated data +

    +
    +
    + + + + + This action cannot be undone +
    + All your data, including templates and usage history, will be + permanently deleted. +
    +
    + +
    +
    ) } diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx index 06d76d9228..08bd53153f 100644 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ b/frontend/src/components/UserSettings/DeleteConfirmation.tsx @@ -43,8 +43,8 @@ const DeleteConfirmation = () => { return ( - diff --git a/frontend/src/components/UserSettings/MonthlyUsage.tsx b/frontend/src/components/UserSettings/MonthlyUsage.tsx new file mode 100644 index 0000000000..14a8d9bc88 --- /dev/null +++ b/frontend/src/components/UserSettings/MonthlyUsage.tsx @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query" +import { Gift } from "lucide-react" + +import { StoragesService } from "@/client" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Progress } from "@/components/ui/progress" + +const FREE_TIER_PAGE_LIMIT = 25 + +function getNextMonthReset(): string { + const now = new Date() + const next = new Date(now.getFullYear(), now.getMonth() + 1, 1) + return next.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }) +} + +const MonthlyUsage = () => { + const { data: storageStat } = useQuery({ + queryKey: ["storageStat"], + queryFn: () => StoragesService.getMyStorageStat(), + }) + + const usedPages = storageStat?.extracted_pages ?? 0 + const percentUsed = Math.min((usedPages / FREE_TIER_PAGE_LIMIT) * 100, 100) + const resetDate = getNextMonthReset() + + return ( + + +
    + Monthly Usage +

    + Track your document processing usage +

    +
    +
    + +
    +
    +
    + + Page Usage + + Free Trial + +
    +
    + + {usedPages} / {FREE_TIER_PAGE_LIMIT} + +

    + {percentUsed.toFixed(1)}% used +

    +
    +
    + +
    +

    + Resets on {resetDate} +

    +
    +
    + ) +} + +export default MonthlyUsage diff --git a/frontend/src/components/UserSettings/PaymentMethods.tsx b/frontend/src/components/UserSettings/PaymentMethods.tsx new file mode 100644 index 0000000000..f4e267463d --- /dev/null +++ b/frontend/src/components/UserSettings/PaymentMethods.tsx @@ -0,0 +1,36 @@ +import { Link } from "@tanstack/react-router" +import { CreditCard } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +const PaymentMethods = () => { + return ( + + + +
    + Payment Methods +

    + Manage your payment information +

    +
    +
    + +
    + +

    + No payment methods on file +

    + + + +
    +
    +
    + ) +} + +export default PaymentMethods diff --git a/frontend/src/components/UserSettings/SubscriptionPlan.tsx b/frontend/src/components/UserSettings/SubscriptionPlan.tsx new file mode 100644 index 0000000000..a0a5e4216a --- /dev/null +++ b/frontend/src/components/UserSettings/SubscriptionPlan.tsx @@ -0,0 +1,44 @@ +import { Link } from "@tanstack/react-router" +import { XCircle, Zap } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +const SubscriptionPlan = () => { + return ( + + +
    + +
    + Subscription Plan +

    + Manage your subscription and billing +

    +
    +
    + + Free Demo + +
    + +
    + + No active subscription +
    +

    + Upgrade to unlock more pages, files, and advanced features. +

    + + + +
    +
    + ) +} + +export default SubscriptionPlan diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000000..a558b9ba0b --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -0,0 +1,28 @@ +import * as ProgressPrimitive from "@radix-ui/react-progress" +import type * as React from "react" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index e109b5ae81..284560e4e5 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -2,16 +2,11 @@ import { createFileRoute } from "@tanstack/react-router" import ChangePassword from "@/components/UserSettings/ChangePassword" import DeleteAccount from "@/components/UserSettings/DeleteAccount" -import UserInformation from "@/components/UserSettings/UserInformation" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import MonthlyUsage from "@/components/UserSettings/MonthlyUsage" +import PaymentMethods from "@/components/UserSettings/PaymentMethods" +import SubscriptionPlan from "@/components/UserSettings/SubscriptionPlan" import useAuth from "@/hooks/useAuth" -const tabsConfig = [ - { value: "my-profile", title: "My profile", component: UserInformation }, - { value: "password", title: "Password", component: ChangePassword }, - { value: "danger-zone", title: "Danger zone", component: DeleteAccount }, -] - export const Route = createFileRoute("/_layout/settings")({ component: UserSettings, head: () => ({ @@ -25,37 +20,22 @@ export const Route = createFileRoute("/_layout/settings")({ function UserSettings() { const { user: currentUser } = useAuth() - const finalTabs = currentUser?.is_superuser - ? tabsConfig.slice(0, 3) - : tabsConfig if (!currentUser) { return null } return ( -
    +
    -

    User Settings

    -

    - Manage your account settings and preferences -

    +

    Account Settings

    - - - {finalTabs.map((tab) => ( - - {tab.title} - - ))} - - {finalTabs.map((tab) => ( - - - - ))} - + + + + +
    ) } diff --git a/package-lock.json b/package-lock.json index f3acd5d09f..b2df991b61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -93,7 +94,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -490,6 +490,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -505,6 +506,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -520,6 +522,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -535,6 +538,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -550,6 +554,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -565,6 +570,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -580,6 +586,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -595,6 +602,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -610,6 +618,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -625,6 +634,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -640,6 +650,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -655,6 +666,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -670,6 +682,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -685,6 +698,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -700,6 +714,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -715,6 +730,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -730,6 +746,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -745,6 +762,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -760,6 +778,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -775,6 +794,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -790,6 +810,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -805,6 +826,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -820,6 +842,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -835,6 +858,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -850,6 +874,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -865,6 +890,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2129,6 +2155,29 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", @@ -2905,6 +2954,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -2917,6 +2967,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -2929,6 +2980,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -2941,6 +2993,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -2953,6 +3006,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -2965,6 +3019,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -2977,6 +3032,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2989,6 +3045,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3001,6 +3058,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3013,6 +3071,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3025,6 +3084,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3037,6 +3097,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3049,6 +3110,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3061,6 +3123,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3073,6 +3136,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3085,6 +3149,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3097,6 +3162,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3109,6 +3175,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3121,6 +3188,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3133,6 +3201,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -3145,6 +3214,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -3157,6 +3227,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -3169,6 +3240,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -3181,6 +3253,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -3193,6 +3266,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -3726,7 +3800,6 @@ "version": "5.95.1", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.1.tgz", "integrity": "sha512-lqWkRuVtcz9p68/LxxKlWS+M4ACuizJUkVPZryKj0RKE8Se6TD8HU7FNy1c1Mv9C1CPBfzj/p/xiXWK9VCsKJQ==", - "peer": true, "dependencies": { "@tanstack/query-core": "5.95.1" }, @@ -3758,7 +3831,6 @@ "version": "1.168.2", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.2.tgz", "integrity": "sha512-zUDRM01m81xDCeTLHuqsvKR9zpf+bdfEhyadcUNSbO1930lIeOKLmMscUUNHWhc7Gqpi/V8Xl85QcJFAIAGmvQ==", - "peer": true, "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.2", @@ -3843,7 +3915,6 @@ "version": "1.168.2", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.2.tgz", "integrity": "sha512-9wHR7syfY7y/qrvTvv8bugh6mrKk58TuiSQp44nbGW0BpE2+IIta1DBeL5jHr9AD1a+c5fVKSu/JXsKeniUc9w==", - "peer": true, "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", @@ -4075,7 +4146,8 @@ "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -4087,8 +4159,7 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "devOptional": true, - "peer": true, + "dev": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4097,8 +4168,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, - "peer": true, + "dev": true, "dependencies": { "csstype": "^3.2.2" } @@ -4107,8 +4177,7 @@ "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==", - "devOptional": true, - "peer": true, + "dev": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4283,7 +4352,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4539,7 +4607,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "peer": true + "dev": true }, "node_modules/dayjs": { "version": "1.11.20", @@ -4734,6 +4802,7 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -4876,6 +4945,7 @@ "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, "optional": true, "os": [ @@ -4949,7 +5019,7 @@ "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "devOptional": true, + "dev": true, "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -5603,6 +5673,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -5711,7 +5782,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==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", @@ -5784,6 +5856,7 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5841,7 +5914,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5850,7 +5922,6 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5870,7 +5941,6 @@ "version": "7.72.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5997,7 +6067,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "dev": true, "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } @@ -6006,6 +6076,7 @@ "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6075,7 +6146,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", - "peer": true, "engines": { "node": ">=10" } @@ -6183,6 +6253,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -6198,6 +6269,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -6214,7 +6286,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true, + "dev": true, "engines": { "node": ">=12" }, @@ -6243,7 +6315,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6272,7 +6344,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6304,7 +6375,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true + "dev": true }, "node_modules/unplugin": { "version": "2.3.11", @@ -6416,7 +6487,7 @@ "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "peer": true, + "dev": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6490,6 +6561,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "engines": { "node": ">=12.0.0" }, @@ -6506,7 +6578,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true, + "dev": true, "engines": { "node": ">=12" }, @@ -6564,7 +6636,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -6809,156 +6880,182 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "dev": true, "optional": true }, "@floating-ui/core": { @@ -7107,8 +7204,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7184,8 +7280,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7223,8 +7318,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7258,8 +7352,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7282,14 +7375,12 @@ "@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "requires": {} + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==" }, "@radix-ui/react-context": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", - "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", - "requires": {} + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==" }, "@radix-ui/react-dialog": { "version": "1.1.15", @@ -7315,8 +7406,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7339,8 +7429,7 @@ "@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "requires": {} + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==" }, "@radix-ui/react-dismissable-layer": { "version": "1.1.11", @@ -7389,8 +7478,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7413,8 +7501,7 @@ "@radix-ui/react-focus-guards": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "requires": {} + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==" }, "@radix-ui/react-focus-scope": { "version": "1.1.7", @@ -7488,8 +7575,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7529,8 +7615,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7594,6 +7679,15 @@ "@radix-ui/react-slot": "1.2.4" } }, + "@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "requires": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + } + }, "@radix-ui/react-radio-group": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", @@ -7614,8 +7708,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7654,8 +7747,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7694,8 +7786,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7746,8 +7837,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7801,8 +7891,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7844,8 +7933,7 @@ "@radix-ui/react-context": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "requires": {} + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==" }, "@radix-ui/react-primitive": { "version": "2.1.3", @@ -7868,8 +7956,7 @@ "@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "requires": {} + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==" }, "@radix-ui/react-use-controllable-state": { "version": "1.2.2", @@ -7907,14 +7994,12 @@ "@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "requires": {} + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==" }, "@radix-ui/react-use-previous": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", - "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", - "requires": {} + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==" }, "@radix-ui/react-use-rect": { "version": "1.1.1", @@ -7973,150 +8058,175 @@ "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "dev": true, "optional": true }, "@rollup/rollup-freebsd-arm64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "dev": true, "optional": true }, "@rollup/rollup-freebsd-x64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "dev": true, "optional": true }, "@rollup/rollup-linux-loong64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "dev": true, "optional": true }, "@rollup/rollup-linux-loong64-musl": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "dev": true, "optional": true }, "@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "dev": true, "optional": true }, "@rollup/rollup-linux-ppc64-musl": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-musl": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "dev": true, "optional": true }, "@rollup/rollup-openbsd-x64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "dev": true, "optional": true }, "@rollup/rollup-openharmony-arm64": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "dev": true, "optional": true }, "@rollup/rollup-win32-x64-gnu": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "dev": true, "optional": true }, "@standard-schema/utils": { @@ -8387,7 +8497,6 @@ "version": "5.95.1", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.1.tgz", "integrity": "sha512-lqWkRuVtcz9p68/LxxKlWS+M4ACuizJUkVPZryKj0RKE8Se6TD8HU7FNy1c1Mv9C1CPBfzj/p/xiXWK9VCsKJQ==", - "peer": true, "requires": { "@tanstack/query-core": "5.95.1" } @@ -8404,7 +8513,6 @@ "version": "1.168.2", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.2.tgz", "integrity": "sha512-zUDRM01m81xDCeTLHuqsvKR9zpf+bdfEhyadcUNSbO1930lIeOKLmMscUUNHWhc7Gqpi/V8Xl85QcJFAIAGmvQ==", - "peer": true, "requires": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.2", @@ -8441,7 +8549,6 @@ "version": "1.168.2", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.2.tgz", "integrity": "sha512-9wHR7syfY7y/qrvTvv8bugh6mrKk58TuiSQp44nbGW0BpE2+IIta1DBeL5jHr9AD1a+c5fVKSu/JXsKeniUc9w==", - "peer": true, "requires": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", @@ -8558,7 +8665,8 @@ "@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true }, "@types/json-schema": { "version": "7.0.15", @@ -8570,8 +8678,7 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "devOptional": true, - "peer": true, + "dev": true, "requires": { "undici-types": "~7.18.0" } @@ -8580,8 +8687,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, - "peer": true, + "dev": true, "requires": { "csstype": "^3.2.2" } @@ -8590,9 +8696,7 @@ "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==", - "devOptional": true, - "peer": true, - "requires": {} + "dev": true }, "@vitejs/plugin-react-swc": { "version": "4.3.0", @@ -8708,7 +8812,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8881,7 +8984,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "peer": true + "dev": true }, "dayjs": { "version": "1.11.20", @@ -9016,6 +9119,7 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, "requires": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", @@ -9096,6 +9200,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "*", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -9167,6 +9272,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "optional": true }, "function-bind": { @@ -9215,7 +9321,7 @@ "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "devOptional": true, + "dev": true, "requires": { "resolve-pkg-maps": "^1.0.0" } @@ -9247,8 +9353,7 @@ "goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", - "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", - "requires": {} + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==" }, "gopd": { "version": "1.2.0", @@ -9496,8 +9601,7 @@ "lucide-react": { "version": "0.563.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", - "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", - "requires": {} + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==" }, "magic-string": { "version": "0.30.21", @@ -9591,7 +9695,8 @@ "nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true }, "neo-async": { "version": "2.6.2", @@ -9602,8 +9707,7 @@ "next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", - "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", - "requires": {} + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==" }, "node-fetch-native": { "version": "1.6.7", @@ -9670,7 +9774,8 @@ "picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true }, "picomatch": { "version": "2.3.1", @@ -9718,6 +9823,7 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -9748,14 +9854,12 @@ "react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "peer": true + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" }, "react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "peer": true, "requires": { "scheduler": "^0.27.0" } @@ -9763,21 +9867,17 @@ "react-error-boundary": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", - "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", - "requires": {} + "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==" }, "react-hook-form": { "version": "7.72.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", - "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", - "peer": true, - "requires": {} + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==" }, "react-icons": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", - "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", - "requires": {} + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==" }, "react-remove-scroll": { "version": "2.7.2", @@ -9843,12 +9943,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true + "dev": true }, "rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, "requires": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", @@ -9899,20 +10000,17 @@ "seroval": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", - "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", - "peer": true + "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==" }, "seroval-plugins": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.1.tgz", - "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==", - "requires": {} + "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==" }, "sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", - "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", - "requires": {} + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==" }, "source-map": { "version": "0.7.6", @@ -9978,6 +10076,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "requires": { "fdir": "^6.5.0", "picomatch": "^4.0.3" @@ -9987,13 +10086,13 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "requires": {} + "dev": true }, "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true + "dev": true } } }, @@ -10015,7 +10114,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "requires": { "esbuild": "~0.27.0", "fsevents": "~2.3.3", @@ -10032,8 +10131,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "peer": true + "dev": true }, "ufo": { "version": "1.6.3", @@ -10052,7 +10150,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true + "dev": true }, "unplugin": { "version": "2.3.11", @@ -10104,14 +10202,13 @@ "use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "requires": {} + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==" }, "vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "peer": true, + "dev": true, "requires": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10126,13 +10223,13 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "requires": {} + "dev": true }, "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true + "dev": true } } }, From 8da2e2b289d3d8840a69b4ed217ef729d1364225 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Wed, 8 Apr 2026 12:52:55 +0700 Subject: [PATCH 11/30] update front end --- backend/app/files/router.py | 43 +- backend/app/files/service.py | 18 +- docs/upload-and-parse-flow.md | 371 ++++++++++++++++++ frontend/src/components/Auth/AuthModal.tsx | 268 +++++++++++++ frontend/src/components/Auth/LoginModal.tsx | 101 +++++ frontend/src/components/Auth/SignupModal.tsx | 132 +++++++ frontend/src/components/Navbar.tsx | 50 --- frontend/src/components/footer.tsx | 73 ++-- frontend/src/components/header.tsx | 34 +- frontend/src/hooks/useAuth.ts | 2 +- frontend/src/routeTree.gen.ts | 47 +-- frontend/src/routes/_layout/index.tsx | 326 --------------- frontend/src/routes/_public.tsx | 1 - .../routes/_public/{home.tsx => index.tsx} | 2 +- frontend/src/routes/_public/pricing.tsx | 2 +- frontend/src/routes/login.tsx | 145 +++---- 16 files changed, 1065 insertions(+), 550 deletions(-) create mode 100644 docs/upload-and-parse-flow.md create mode 100644 frontend/src/components/Auth/AuthModal.tsx create mode 100644 frontend/src/components/Auth/LoginModal.tsx create mode 100644 frontend/src/components/Auth/SignupModal.tsx delete mode 100644 frontend/src/components/Navbar.tsx delete mode 100644 frontend/src/routes/_layout/index.tsx rename frontend/src/routes/_public/{home.tsx => index.tsx} (99%) diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 8936fa04fd..95b65d5167 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -15,10 +15,9 @@ create_file, delete_file, download_excel_file, - update_file_job_info, + update_file_job_info, handle_uploaded_file, ) from app.ocrs.service import get_job_status, update_ocr_job_status, upload_ocr_job -from app.storages.service import increment_storage_stat router = APIRouter(prefix="/files", tags=["files"]) @@ -36,28 +35,34 @@ def upload_file_endpoint( file_type = file.content_type or "application/octet-stream" file_create = FileCreate(filename=file_name, content_type=file_type, size=len(file_bytes), url="") file_result = create_file(session=session, file_in=file_create, user_id=user.id) - try: - r2_result = upload_file_to_r2( - key=user.email + "/" + str(file_result.id) + "/" + file_name, # Use DB record ID for unique key - data=file_bytes, - content_type=file_type, - presign=True - ) - if not r2_result.get("IsSuccess"): - delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure - raise HTTPException(status_code=500, detail="Failed to upload file to R2") - - (is_success, msg) = upload_ocr_job(session=session, file=file_result, file_url=r2_result["PresignedURL"]) - if not is_success: - delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure - raise HTTPException(status_code=500, detail=f"OCR job upload failed: {msg}") + handle_uploaded_file(session=session, file=file_result, user=user, file_bytes=file_bytes) # Upload to R2 and enqueue OCR job return file_result - except Exception as exc: delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure - logger.error(f"Error uploading file {file_name}: {exc}") + logger.error(f"Error handling uploaded file {file_name}: {exc}") raise HTTPException(status_code=500, detail=str(exc)) + # try: + # r2_result = upload_file_to_r2( + # key=user.email + "/" + str(file_result.id) + "/" + file_name, # Use DB record ID for unique key + # data=file_bytes, + # content_type=file_type, + # presign=True + # ) + # if not r2_result.get("IsSuccess"): + # delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + # raise HTTPException(status_code=500, detail="Failed to upload file to R2") + + # (is_success, msg) = upload_ocr_job(session=session, file=file_result, file_url=r2_result["PresignedURL"]) + # if not is_success: + # delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + # raise HTTPException(status_code=500, detail=f"OCR job upload failed: {msg}") + # return file_result + + # except Exception as exc: + # delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + # logger.error(f"Error uploading file {file_name}: {exc}") + # raise HTTPException(status_code=500, detail=str(exc)) @router.get("/presign", response_model=PresignResponse) def presign_upload(req: PresignRequest): diff --git a/backend/app/files/service.py b/backend/app/files/service.py index f07cd10471..98643e19f7 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -5,7 +5,7 @@ import pandas as pd from sqlmodel import Session -from app.aws.client import generate_presigned_put_url +from app.aws.client import generate_presigned_put_url, upload_file_to_r2 from app.aws.config import aws_settings from app.files.dependencies import CurrentUser from app.files.models import File @@ -13,6 +13,22 @@ from app.files.utils import get_df_from_result_json +def handle_uploaded_file(*, session: Session, file: File, user: CurrentUser, file_bytes: bytes) -> None: + """ + Handle a newly uploaded file by creating a File record in the database. + The actual file content is expected to be uploaded separately to the provided presigned URL. + """ + # upload to r2 + r2_result = upload_file_to_r2( + key=user.email + "/" + str(file.id) + "/" + file.filename, # Use DB record ID for unique key + data=file_bytes, + content_type=file.content_type, + presign=True + ) + + # enqueue OCR job + + def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: db_file = File.model_validate(file_in, update={"user_id": user_id}) session.add(db_file) diff --git a/docs/upload-and-parse-flow.md b/docs/upload-and-parse-flow.md new file mode 100644 index 0000000000..f5105713c5 --- /dev/null +++ b/docs/upload-and-parse-flow.md @@ -0,0 +1,371 @@ +## Upload & Parse Flow + +Purpose +------- + +This document describes a production-ready end-to-end flow for "user uploads a document → server persists file to R2 → OCR parsing runs (parallel) → client polls for result." It includes the sequence, API contracts, data shapes, orchestration, error handling, security, and examples. + +High-level contract +------------------- +- Input: user-authenticated file upload (single file or multipart). Output: job id that can be polled for status and final parse result. +- Processing: file stored durably in R2, a Job + Document record created, parsing tasks submitted to workers/OCR provider in parallel, partial results persisted and aggregated. +- Success: final status = `completed` with parsed data and extracted metadata. Error modes: transient (retryable) vs permanent (manual intervention). +- Non-functional: idempotent uploads, rate-limited OCR calls, auditable operations, monitoring and alerts for failures. + +Edge cases to handle +-------------------- +- Very large files (large PDFs, many pages) → chunking or reject with guidance. +- Duplicate uploads (user retries) → idempotency via `client_upload_id` or checksum. +- OCR provider rate limits / outages → queue + backoff + DLQ. +- Partial parsing (some pages fail) → aggregated job-level status with per-page errors. +- PII / compliance: redact or restrict storage of sensitive fields when storing parsed text (policy). + +Sequence overview +----------------- + +1. Client POST `/upload` (or presigned PUT) with file + metadata. +2. Server stores file to R2 (or returns presigned URL to client for direct upload). +3. Server creates DB Job and Document metadata, enqueues parsing tasks (per-file or per-page). +4. Worker pool consumes tasks, calls external OCR API in parallel, stores ParseResult(s) to DB/R2. +5. Aggregation completes, Job status updated to `completed`/`failed`. Client polls `/jobs/{job_id}` or receives webhook. + +Design choices & rationale +------------------------- +- Store original file in R2 (cheap, durable); keep references in DB (filename, size, checksum, R2 key). +- Prefer server-generated presigned PUT URLs for large uploads to avoid server memory pressure. +- Use a queue (Redis + RQ/Celery, or SQS) + worker pool to decouple ingestion from slow OCR API calls. +- Split work into smaller units (pages or chunks) for parallelism and better error isolation. +- Provide both polling and webhook options for clients; polling is simpler and robust for many clients. + +API Contracts +------------- + +1) Upload endpoint (server-mediated or presigned) + +Option A — Server-handled upload (multipart) + +- POST `/upload` +- Auth: Bearer JWT +- Request: `multipart/form-data` { file: file, metadata?: JSON, client_upload_id?: string } +- Response `202 Accepted` + +``` +{ + "job_id": "uuid", + "document_id": "uuid", + "status": "pending", + "poll_url": "/jobs/{job_id}" +} +``` + +Option B — Presigned upload (recommended for large uploads) + +- POST `/uploads/presign` +- Request JSON: + +``` +{ "filename": "file.pdf", "content_type": "application/pdf", "client_upload_id?": "string", "metadata?": { ... } } +``` +- Response 200: + +``` +{ + "upload_url": "https://r2...signed-url", + "file_key": "r2-key", + "job_id": "uuid", + "document_id": "uuid", + "complete_upload_url": "/uploads/complete" +} +``` +- Client: PUT file to `upload_url`. Then POST `/uploads/complete` { file_key, job_id } to signal server to start parsing. + +2) Start/Complete upload (server) + +- POST `/uploads/complete` +- Body: `{ job_id: "uuid", file_key: "string", checksum?: "sha256", metadata?: {} }` +- Response 200 -> same job_id. + +3) Polling/Get job + +- GET `/jobs/{job_id}` +- Auth: Bearer JWT (owners or admins) +- Response: + +``` +{ + "job_id":"uuid", + "document_id":"uuid", + "status":"pending|processing|partial|completed|failed", + "created_at":"iso", + "updated_at":"iso", + "progress": { "total_tasks": 10, "completed_tasks": 7 }, + "results": [ { "page":1,"status":"completed","result_key":"r2-key-or-db-id" }, ... ], + "error": { "message": "...", "code": "OCR_PROVIDER_429" } +} +``` + +4) Get parse result + +- GET `/documents/{document_id}/result` or `/jobs/{job_id}/result` +- Support pagination for large parse outputs or per-page retrieval. + +5) Webhook (optional) + +- POST `/webhooks/parse-complete` +- Body: `{ job_id, document_id, status, summary: {...} }` +- Security: sign webhook (HMAC) or mutual TLS. + +Data Models (conceptual) +------------------------ +- Job + - id uuid PK + - user_id uuid FK + - status enum + - client_upload_id nullable string (idempotency) + - created_at, updated_at + - error JSON nullable +- Document + - id uuid + - job_id uuid + - r2_key string + - filename string + - size int + - checksum string + - pages int nullable +- Task (optional — per-page) + - id uuid + - document_id + - page_number int nullable + - status enum + - attempts int + - last_error JSON + - result_key string (link to parsed output in R2 or DB) +- ParseResult + - id uuid + - document_id + - task_id + - extracted_text (or link) + - structured_data JSON + - created_at + +Storage conventions (R2) +----------------------- +- Bucket layout: + - `originals/{year}/{month}/{job_id}/{document_id}/{filename}` + - `results/{year}/{month}/{job_id}/{document_id}/page-{n}.json` +- Filenames: `{uuid}_{sanitized_name}` for traceability. +- Store checksums (sha256) & content-type. +- Lifecycle: set retention policy if needed; consider lifecycle rules to move older originals to cold storage. +- Signed URLs: presigned PUT for upload and presigned GET for downloads; short TTL (e.g., 15m). + +OCR orchestration & parallel parsing +----------------------------------- +- Chunking: + - For PDFs: extract pages server-side (if needed) and submit one task per page. + - For images: per-file task. +- Worker pool: + - Use a queue (Redis, SQS) and workers (Celery/RQ or managed pool). + - Concurrency limits: set by OCR provider rate limits and CPU/memory costs. + - Task flow: worker fetches task -> download file/page from R2 -> call OCR -> store result -> ack. +- Parallelism & rate limiting: + - Workers implement a rate-limited client for OCR provider (token bucket). + - For bursty loads, use a local queue and throttle to avoid exceeding external quotas. +- Idempotency: + - Each task should have an idempotency key (job_id + document_id + page_number). If a ParseResult exists, skip reprocessing. +- Timeouts & retries: + - Set a sensible timeout for OCR calls (e.g., 60s). Retry transient errors with exponential backoff (max 3-5 attempts). Permanent errors mark task failed and optionally send to DLQ. +- Aggregation: + - A coordinator or the Job record tracks number of tasks vs completed tasks; when all tasks are completed/failed, compute job-level status. + +Polling strategy +---------------- +- Client receives `job_id` and `poll_url`. +- Poll frequency: start aggressive for a short time, then back off exponentially with cap. Example: 1s, 2s, 4s, 8s, 16s, 30s (cap). +- Include `ETag`/`Last-Modified` in responses to reduce payloads. Clients may use conditional requests. +- Provide webhook/SSE/websocket alternative for real-time needs. + +Backoff and retry policy for client polling +----------------------------------------- +- Use exponential backoff with jitter to avoid thundering herd. +- If job age > threshold (e.g., 10 minutes), switch to polling every 30–60s and surface a message to the client that the job is long-running. + +Error handling and retry policy (server) +--------------------------------------- +- Error types: + - Transient: OCR provider 429/5xx, network timeouts → retry with backoff. + - Permanent: invalid file, unsupported format → mark task failed immediately. +- Retries: + - For transient OCR errors: up to N attempts (3–5) with exponential backoff. + - After all attempts fail: write to DLQ and mark task failed; notify via alert. +- Partial success: + - If some pages succeed and others fail, return aggregated results and per-page error details. +- Audit: + - Store full error payloads for debugging in DB or logs (mask PII). +- Circuit breaker: + - If OCR provider returns many failures, temporarily stop making new calls and back off globally. + +Security & compliance +--------------------- +- Auth: Bearer JWT with scopes for upload and job read; verify user owns job/document. +- Signed URLs: presigned PUT/GET URLs with short expiry; restrict methods/headers. +- Input validation: validate content-types, enforce file size limits. +- Malware scanning: integrate virus scanning on upload (optional) before enqueueing. +- PII & encryption: encrypt at rest (R2 settings), TLS in transit; implement data retention and deletion policy. +- Audit logs: log who uploaded, processed, and accessed results. +- Webhook security: sign webhook payloads with HMAC secret. + +Monitoring, metrics & alerts +--------------------------- +- Metrics: + - `job_created`, `job_completed`, `job_failed`, `average_job_time`, `queue_depth`, `worker_count`, `ocr_api_errors`, `ocr_api_throttles`. +- Logs: + - Structured logs for each job/task including `job_id`. +- Alerts: + - Alert on high queue depth, elevated failure rates, OCR provider downtime, or sudden latency spikes. +- Healthcheck endpoints for API and workers. + +Data retention & deletion +------------------------ +- Soft-delete documents (mark as deleted) with a scheduled job to purge after retention period. +- Offer user-initiated deletion that marks job/document and schedules removal from R2 and DB. +- When removing data, also remove parse results and event logs (or archive them as needed for compliance). + +Data schemas / Example JSON +--------------------------- + +Upload response + +``` +{ + "job_id": "7b8e1a2e-....", + "document_id": "a1234bcd-....", + "status": "pending", + "poll_url": "/jobs/7b8e1a2e-...." +} +``` + +Job status response + +``` +{ + "job_id":"7b8e1a2e-....", + "status":"processing", + "progress":{"total_tasks":10,"completed_tasks":6}, + "results":[ + {"page":1,"status":"completed","result_key":"results/.../page-1.json"}, + {"page":2,"status":"failed","error":{"code":"OCR_500","message":"timeout"}} + ], + "error":null +} +``` + +Parse result (per page) + +``` +{ + "document_id":"a1234bcd-....", + "page":1, + "text":"Extracted OCR text...", + "entities":[ {"type":"date","value":"2026-01-23","span":[10,20]} ], + "confidence":0.98 +} +``` + +Example server-side pseudocode (Python / FastAPI + Celery style) +--------------------------------------------------------------- + +1) `POST /uploads/complete` handler +- validate `job_id` and `file_key` +- create `Document` row with `r2_key = file_key` +- compute checksum optionally +- determine splitting strategy (if pdf -> number of pages) +- create `Task` rows per page or per-file +- enqueue each task to queue: `queue.enqueue("process_task", task_id=task.id)` + +2) Worker `process_task(task_id)` +- load Task and Document +- idempotency: if `ParseResult` exists for `task_id` -> return +- download source (R2) to temp +- call OCR client (with timeout, retry wrapper) +- on success -> upload parsed JSON to R2 `results/{job_id}/...` and write `ParseResult` row, mark Task completed +- on failure -> increment attempts, if attempts < max => re-enqueue with backoff, else mark Task.failed and write last_error + +Client-side pseudocode (JS) +--------------------------- +- Upload (presigned): + 1) POST `/uploads/presign` -> get `upload_url` + `job_id`. + 2) PUT `upload_url` file (fetch, axios, etc.) + 3) POST `/uploads/complete` { job_id, file_key } + 4) Poll: GET `/jobs/{job_id}` until `status=completed|failed`. + +Examples (curl) +--------------- + +Presign: + +``` +curl -X POST "https://api.example.com/uploads/presign" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"filename":"doc.pdf","content_type":"application/pdf"}' +``` + +PUT to R2 (presigned URL) + +``` +curl -X PUT "https://r2-presigned-url" -H "Content-Type: application/pdf" --upload-file doc.pdf +``` + +Complete: + +``` +curl -X POST "https://api.example.com/uploads/complete" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"job_id":"...","file_key":"originals/.../doc.pdf"}' +``` + +Poll: + +``` +curl -X GET "https://api.example.com/jobs/{job_id}" -H "Authorization: Bearer $TOKEN" +``` + +Testing & validation +-------------------- +- Unit tests: + - validate upload request validation, DB creation. + - worker idempotency and retry logic (mock OCR client). +- Integration tests (fast): + - use in-memory queue or test Redis; send a small PDF, ensure tasks are created, workers process, final job completed. +- E2E test: + - run with a small OCR test provider or a mocked HTTP server to emulate OCR API responses including 429 and 5xx to validate backoff. +- CI guard: + - avoid calling real OCR provider in CI; mock external HTTP calls. + +Quality gates (quick triage) +--------------------------- +- Build: N/A (docs). Server code should run in local dev. +- Lint/Typecheck: ensure code follows project linters & types. +- Tests: add unit and integration tests for upload/worker flow. + +Operational notes & cost controls +------------------------------- +- Track per-job OCR API calls and cost; add throttling at queue or worker level. +- Provide rate-limiting by user to avoid abuse. +- For high-volume customers consider batching or a dedicated OCR plan with provider. + +Implementation checklist (practical) +---------------------------------- +- [ ] Implement `POST /uploads/presign` and presigned PUT flow. +- [ ] Implement `POST /uploads/complete` to create Document + Tasks. +- [ ] Implement queue + worker with rate-limited OCR client. +- [ ] Implement `GET /jobs/{job_id}`. +- [ ] Add idempotency via `client_upload_id` or checksum. +- [ ] Add monitoring metrics and DLQ. +- [ ] Add webhook option and signing. +- [ ] Add tests and docs (`docs/upload-and-parse-flow.md`). + +Next steps +---------- +- Implement the API endpoint stubs and a worker prototype in `backend/app/ocrs`. +- Add TypeScript/Python SDK snippets for upload + polling. +- Create tests that mock the OCR provider to validate retry/backoff and idempotency. + +--- + +Document created on: 2026-04-08 diff --git a/frontend/src/components/Auth/AuthModal.tsx b/frontend/src/components/Auth/AuthModal.tsx new file mode 100644 index 0000000000..4636211e6c --- /dev/null +++ b/frontend/src/components/Auth/AuthModal.tsx @@ -0,0 +1,268 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import type { Body_login_login_access_token as AccessToken } from "@/client" +import useAuth from "@/hooks/useAuth" +import { useState } from "react" +import { useMutation } from "@tanstack/react-query" +import { LoginService } from "@/client" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" + +const signInSchema = z.object({ + username: z.string().email(), + password: z.string().min(8), +}) satisfies z.ZodType + +const signUpSchema = z + .object({ + email: z.string().email(), + full_name: z.string().min(1), + password: z.string().min(8), + confirm_password: z.string().min(1), + }) + .refine((data) => data.password === data.confirm_password, { + message: "The passwords don't match", + path: ["confirm_password"], + }) + +type SignInData = z.infer +type SignUpData = z.infer + +export default function AuthModal({ + open, + setOpen, + initialTab = "signin", +}: { + open: boolean + setOpen: (open: boolean) => void + initialTab?: "signin" | "signup" +}) { + const { loginMutation, signUpMutation } = useAuth() + const { showSuccessToast, showErrorToast } = useCustomToast() + const [tab, setTab] = useState<"signin" | "signup" | "recover">(initialTab) + + const signInForm = useForm({ + resolver: zodResolver(signInSchema), + mode: "onBlur", + defaultValues: { username: "", password: "" }, + }) + + const signUpForm = useForm({ + resolver: zodResolver(signUpSchema), + mode: "onBlur", + defaultValues: { email: "", full_name: "", password: "", confirm_password: "" }, + }) + + const recoverForm = useForm<{ email: string }>({ + resolver: zodResolver(z.object({ email: z.string().email() })), + mode: "onBlur", + defaultValues: { email: "" }, + }) + + const recoverMutation = useMutation({ + mutationFn: (data: { email: string }) => LoginService.recoverPassword({ email: data.email }), + onSuccess: () => { + showSuccessToast("If that email exists, a recovery message has been sent") + setTab("signin") + }, + onError: handleError.bind(showErrorToast), + }) + + const onSignIn = (data: SignInData) => { + if (loginMutation.isPending) return + loginMutation.mutate(data) + } + + const onSignUp = (data: SignUpData) => { + if (signUpMutation.isPending) return + const { confirm_password: _c, ...submit } = data + signUpMutation.mutate(submit) + } + + const onRecover = (data: { email: string }) => { + if (recoverMutation.isPending) return + recoverMutation.mutate(data) + } + + return ( + + + + Welcome to docs2excel.ai + + Log in and subscribe to unlock advanced features + + + + setTab(v as "signin" | "signup" | "recover")}> + + Sign In + Sign Up + + + +
    + +
    + ( + + Email + + + + + + )} + /> + + ( + +
    + Password + +
    + + + + +
    + )} + /> + + + Sign In + +
    +
    + +
    + + +
    + +
    + ( + + Full name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + + Sign Up + +
    + +
    + Already have an account? +
    +
    + +
    + + +
    + +
    + ( + + Email + + + + + + )} + /> + + + Send Recovery Email + +
    + +
    + Remembered your password? +
    +
    + +
    +
    +
    +
    + ) +} diff --git a/frontend/src/components/Auth/LoginModal.tsx b/frontend/src/components/Auth/LoginModal.tsx new file mode 100644 index 0000000000..f92f3a1f5f --- /dev/null +++ b/frontend/src/components/Auth/LoginModal.tsx @@ -0,0 +1,101 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import type { Body_login_login_access_token as AccessToken } from "@/client" +import useAuth from "@/hooks/useAuth" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" +import { Link as RouterLink } from "@tanstack/react-router" + +const formSchema = z.object({ + username: z.string().email(), + password: z.string().min(8), +}) satisfies z.ZodType + +type FormData = z.infer + +export default function LoginModal({ trigger }: { trigger: React.ReactNode }) { + const { loginMutation } = useAuth() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { username: "", password: "" }, + }) + + const onSubmit = (data: FormData) => { + if (loginMutation.isPending) return + loginMutation.mutate(data) + } + + return ( + + {trigger} + + + Welcome back + Log in to unlock advanced features + + +
    + +
    + ( + + Email + + + + + + )} + /> + + ( + +
    + Password + + Forgot your password? + +
    + + + + +
    + )} + /> + + + Sign In + +
    + +
    + Don't have an account? Sign up +
    +
    + +
    +
    + ) +} diff --git a/frontend/src/components/Auth/SignupModal.tsx b/frontend/src/components/Auth/SignupModal.tsx new file mode 100644 index 0000000000..28e991ed63 --- /dev/null +++ b/frontend/src/components/Auth/SignupModal.tsx @@ -0,0 +1,132 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import useAuth from "@/hooks/useAuth" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" +import { Link as RouterLink } from "@tanstack/react-router" +import type React from "react" + +const formSchema = z + .object({ + email: z.string().email(), + full_name: z.string().min(1), + password: z.string().min(8), + confirm_password: z.string().min(1), + }) + .refine((data) => data.password === data.confirm_password, { + message: "The passwords don't match", + path: ["confirm_password"], + }) + +type FormData = z.infer + +export default function SignupModal({ trigger }: { trigger: React.ReactNode }) { + const { signUpMutation } = useAuth() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { email: "", full_name: "", password: "", confirm_password: "" }, + }) + + const onSubmit = (data: FormData) => { + if (signUpMutation.isPending) return + const { confirm_password: _confirm, ...submit } = data + signUpMutation.mutate(submit) + } + + return ( + + {trigger} + + + Create an account + Sign up to start your free trial + + +
    + +
    + ( + + Full name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + + Sign Up + +
    + +
    + Already have an account? Log in +
    +
    + +
    +
    + ) +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx deleted file mode 100644 index 5a310e47bf..0000000000 --- a/frontend/src/components/Navbar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Link, useRouterState } from "@tanstack/react-router" -import { FileText } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Appearance } from "./Common/Appearance" - -const navItems = [ - { href: "/", label: "Home" }, - { href: "/dashboard", label: "Dashboard" }, - { href: "/pricing", label: "Pricing" }, -] - -export function Navbar() { - const { location } = useRouterState() - const pathname = location.pathname - - return ( - - ) -} diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index 4fcfd4f4f7..bdb0f4318e 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -1,17 +1,20 @@ export default function Footer() { return (
    ) } From a62fa677a0d2358872014d571953dad0a76e7015 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Thu, 9 Apr 2026 01:06:12 +0700 Subject: [PATCH 12/30] refactor code backend --- backend/app/aws/client.py | 5 +- backend/app/backend_pre_start.py | 5 +- backend/app/exceptions/OcrJobException.py | 265 ++++++++++++++++++++++ backend/app/files/router.py | 88 ++----- backend/app/files/service.py | 22 +- backend/app/ocrs/schemas.py | 6 + backend/app/ocrs/service.py | 114 +++++----- backend/app/storages/service.py | 10 +- frontend/src/routes/_layout/dashboard.tsx | 26 ++- 9 files changed, 387 insertions(+), 154 deletions(-) create mode 100644 backend/app/exceptions/OcrJobException.py diff --git a/backend/app/aws/client.py b/backend/app/aws/client.py index dab0736760..3b40a8bd25 100644 --- a/backend/app/aws/client.py +++ b/backend/app/aws/client.py @@ -28,11 +28,11 @@ def generate_presigned_put_url(key: str, bucket: str | None = None, expiration: client = get_s3_client() params = {"Bucket": bucket, "Key": key} - logger.info(f"Generating presigned PUT URL for bucket {bucket} and key {key} with expiration {expiration} seconds") + url = client.generate_presigned_url( ClientMethod="get_object", Params=params, ExpiresIn=expiration ) - logger.info(f"Generated presigned URL for key {key}: {url}") + return url @@ -50,5 +50,4 @@ def upload_file_to_r2(key: str, data: bytes, content_type: str | None = None, pr resp["IsSuccess"] = resp.get("ResponseMetadata", {}).get("HTTPStatusCode", 0) == 200 if presign: resp["PresignedURL"] = generate_presigned_put_url(key=key, bucket=bucket) - return resp diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py index c2f8e29ae1..e378a3c66e 100644 --- a/backend/app/backend_pre_start.py +++ b/backend/app/backend_pre_start.py @@ -6,7 +6,10 @@ from app.core.db import engine -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", +) logger = logging.getLogger(__name__) max_tries = 60 * 5 # 5 minutes diff --git a/backend/app/exceptions/OcrJobException.py b/backend/app/exceptions/OcrJobException.py new file mode 100644 index 0000000000..c34a700163 --- /dev/null +++ b/backend/app/exceptions/OcrJobException.py @@ -0,0 +1,265 @@ +"""OCR provider exception types and mapping utilities. + +This module defines a base `OcrJobException` and common subclasses that represent +error conditions returned by external OCR providers (Baidu OCR, etc.). It also +provides a small factory `from_baidu_response` which can translate a typical +Baidu OCR error payload (e.g. containing ``error_code`` and ``error_msg``) into a +rich Python exception subclass. + +The goal is to centralize mapping provider error payloads to typed exceptions +so callers (workers, API handlers) can decide whether an error is retryable, +transient, or permanent. + +Reference: https://ai.baidu.com/ai-doc/AISTUDIO/Mml7n69e7 (provider error shapes) +""" + +from __future__ import annotations + +from typing import Any + + +class OcrJobException(Exception): + """Base exception for OCR job related errors. + + Attributes + ---------- + code: int | None - provider-specific numeric error code when available + message: str - human-readable message + http_status: int | None - optional HTTP status associated with the error + meta: dict - optional extra data (raw provider payload) + """ + + def __init__(self, message: str | None = None, *, code: int | None = None, + http_status: int | None = None, meta: dict | None = None) -> None: + super().__init__(message or "OCR job error") + self.code = code + self.message = message or "OCR job error" + self.http_status = http_status + self.meta = meta or {} + + def to_dict(self) -> dict[str, Any]: + return { + "type": self.__class__.__name__, + "code": self.code, + "message": self.message, + "http_status": self.http_status, + "meta": self.meta, + } + + def __str__(self) -> str: # pragma: no cover - trivial + return f"{self.__class__.__name__}(code={self.code}, message={self.message})" + + +# --- Specific exception subclasses --- + + +class BadRequestError(OcrJobException): + """400-like error: invalid request or parameters.""" + + +class AuthenticationError(OcrJobException): + """Authentication failed (invalid/expired access token).""" + + +class AuthorizationError(OcrJobException): + """Permission denied for the requested resource or action.""" + + +class NotFoundError(OcrJobException): + """Requested resource not found.""" + + +class RateLimitError(OcrJobException): + """Rate limit exceeded (throttling).""" + + +class QuotaExceededError(OcrJobException): + """Account or project quota exhausted.""" + + +class UnsupportedFileTypeError(OcrJobException): + """Uploaded file type is not supported by the OCR provider.""" + + +class FileTooLargeError(OcrJobException): + """Uploaded file exceeds provider/max size limits.""" + + +class ProviderTimeoutError(OcrJobException): + """The OCR provider timed out while processing the request.""" + + +class ServiceUnavailableError(OcrJobException): + """Provider service temporarily unavailable (5xx or maintenance).""" + + +class InternalServerError(OcrJobException): + """Unexpected provider-side error.""" + + +class ConflictError(OcrJobException): + """Conflict (e.g., duplicate resource)""" + + +class NetworkError(OcrJobException): + """Network-level failure when calling the provider.""" + + +class InvalidArgumentError(BadRequestError): + """Invalid argument or malformed request payload.""" + + +# Generic provider error fallback + + +class ProviderError(OcrJobException): + """Generic OCR provider error when no better mapping exists.""" + + +# Mapping helpers +_DEFAULT_MAPPING: dict[str, type[OcrJobException]] = { + # textual heuristics + "access token": AuthenticationError, + "access_token": AuthenticationError, + "permission": AuthorizationError, + "permission denied": AuthorizationError, + "quota": QuotaExceededError, + "limit": RateLimitError, + "rate limit": RateLimitError, + "file too large": FileTooLargeError, + "size limit": FileTooLargeError, + "unsupported": UnsupportedFileTypeError, + "format": UnsupportedFileTypeError, + "timeout": ProviderTimeoutError, + "service unavailable": ServiceUnavailableError, + "internal error": InternalServerError, + "internal server error": InternalServerError, +} + + +def _guess_from_message(msg: str) -> type[OcrJobException]: + if not msg: + return ProviderError + lowered = msg.lower() + for key, exc in _DEFAULT_MAPPING.items(): + if key in lowered: + return exc + # fallback by heuristics + if "unauthorized" in lowered or "invalid token" in lowered: + return AuthenticationError + if "forbidden" in lowered: + return AuthorizationError + if "not found" in lowered: + return NotFoundError + if "429" in lowered or "rate" in lowered: + return RateLimitError + return ProviderError + + +def from_baidu_response(payload: dict[str, Any], http_status: int | None = None) -> OcrJobException: + """Create an OcrJobException from a Baidu OCR provider response payload. + + Expected payload shapes (examples): + - {"error_code": 110, "error_msg": "Invalid access token"} + - {"error": "...", "error_description": "..."} + + This function attempts to detect common keys and returns a concrete + subclass when possible. If no mapping is found it returns ``ProviderError``. + """ + + # Normalized extraction + code: int | None = None + message: str | None = None + if not payload: + return ProviderError("empty response from provider", code=None, http_status=http_status, meta={}) + + # Safely parse numeric codes (they may be strings or ints) + ec = payload.get("error_code") + if ec is None: + ec = payload.get("errno") + try: + if ec is not None: + code = int(ec) + except Exception: + code = None + + message = payload.get("error_msg") or payload.get("error_description") or payload.get("error") or payload.get("message") + + # Some providers return nested data; keep raw payload for debugging + meta = {"provider_payload": payload} + + # Map specific numeric codes if we know them (extendable) + if code is not None: + # Common mapping by numeric code (examples / placeholders). + # Extend this mapping with concrete Baidu codes if known. + if code in (110, 111, 112): # token / auth related (example) + return AuthenticationError(message or "authentication failed", code=code, http_status=http_status, meta=meta) + if code in (17, 18, 19): # quota/limit examples + return QuotaExceededError(message or "quota exceeded", code=code, http_status=http_status, meta=meta) + if 400 <= code < 500: + return BadRequestError(message or "client error", code=code, http_status=http_status, meta=meta) + if 500 <= code < 600: + return ServiceUnavailableError(message or "provider server error", code=code, http_status=http_status, meta=meta) + + # If no numeric mapping, try to guess from message text + exc_cls = _guess_from_message(message or "") + return exc_cls(message or "provider error", code=code, http_status=http_status, meta=meta) + + +def raise_for_baidu_response(payload: dict[str, Any], http_status: int | None = None) -> None: + """Convenience helper: raise an OcrJobException if the payload represents an error. + + This inspects the payload for common error keys and raises a mapped exception. + Callers can catch ``OcrJobException`` or specific subclasses to handle retries. + """ + if not payload: + return + + # Heuristic: if provider included an explicit error key + error_present = any(k in payload for k in ("error", "error_msg", "error_code", "errno", "message")) + if not error_present: + return + + # If payload contains numeric `error_code` or textual `error_msg` treat as error + # NOTE: sometimes providers embed success metadata alongside errors; adjust as needed. + is_error = False + if payload.get("error"): + is_error = True + if payload.get("error_msg"): + is_error = True + if payload.get("error_code") is not None: + # treat non-zero numeric error codes as error + try: + ec = int(payload.get("error_code")) + if ec != 0: + is_error = True + except Exception: + is_error = True + + if not is_error: + return + + exc = from_baidu_response(payload, http_status=http_status) + raise exc + + +__all__ = [ + "OcrJobException", + "BadRequestError", + "AuthenticationError", + "AuthorizationError", + "NotFoundError", + "RateLimitError", + "QuotaExceededError", + "UnsupportedFileTypeError", + "FileTooLargeError", + "ProviderTimeoutError", + "ServiceUnavailableError", + "InternalServerError", + "ConflictError", + "NetworkError", + "InvalidArgumentError", + "ProviderError", + "from_baidu_response", + "raise_for_baidu_response", +] diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 95b65d5167..f79b9bbbd7 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -15,9 +15,9 @@ create_file, delete_file, download_excel_file, - update_file_job_info, handle_uploaded_file, + update_file_info, ) -from app.ocrs.service import get_job_status, update_ocr_job_status, upload_ocr_job +from app.ocrs.service import get_ocr_job_status, post_ocr_jobs router = APIRouter(prefix="/files", tags=["files"]) @@ -35,53 +35,29 @@ def upload_file_endpoint( file_type = file.content_type or "application/octet-stream" file_create = FileCreate(filename=file_name, content_type=file_type, size=len(file_bytes), url="") file_result = create_file(session=session, file_in=file_create, user_id=user.id) + key = user.email + "/" + str(file_result.id) + "/" + file_name + try: - handle_uploaded_file(session=session, file=file_result, user=user, file_bytes=file_bytes) # Upload to R2 and enqueue OCR job + # upload to r2 + r2_result = upload_file_to_r2( + key=key, # Use DB record ID for unique key + data=file_bytes, + content_type=file.content_type, + presign=True + ) + + # enqueue OCR job + if not r2_result.get("IsSuccess"): + delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure + raise HTTPException(status_code=500, detail="Failed to upload file to R2") + + post_ocr_jobs(session=session, file=file_result, file_url=r2_result["PresignedURL"]) + return file_result except Exception as exc: delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure logger.error(f"Error handling uploaded file {file_name}: {exc}") raise HTTPException(status_code=500, detail=str(exc)) - # try: - # r2_result = upload_file_to_r2( - # key=user.email + "/" + str(file_result.id) + "/" + file_name, # Use DB record ID for unique key - # data=file_bytes, - # content_type=file_type, - # presign=True - # ) - # if not r2_result.get("IsSuccess"): - # delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure - # raise HTTPException(status_code=500, detail="Failed to upload file to R2") - - # (is_success, msg) = upload_ocr_job(session=session, file=file_result, file_url=r2_result["PresignedURL"]) - # if not is_success: - # delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure - # raise HTTPException(status_code=500, detail=f"OCR job upload failed: {msg}") - # return file_result - - # except Exception as exc: - # delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure - # logger.error(f"Error uploading file {file_name}: {exc}") - # raise HTTPException(status_code=500, detail=str(exc)) - -@router.get("/presign", response_model=PresignResponse) -def presign_upload(req: PresignRequest): - """ - Generate a presigned PUT URL for direct client uploads. - """ - from app.aws.client import generate_presigned_put_url - - if not aws_settings.R2_BUCKET_NAME and not req.bucket: - raise HTTPException(status_code=500, detail="S3 bucket not configured") - - key = req.filename - try: - url = generate_presigned_put_url(key=key, bucket=req.bucket, expiration=3600) - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) - - return PresignResponse(url=url, key=key) - @router.put("/{file_id}?job_status={job_status}") def update_file_job_status_endpoint( @@ -93,7 +69,7 @@ def update_file_job_status_endpoint( Update the job status for a file based on OCR job updates. """ - updated_file = update_file_job_info(session=session, file_id=file_id, job_status=job_status) + updated_file = update_file_info(session=session, file_id=file_id, job_status=job_status) if not updated_file: raise HTTPException(status_code=404, detail="File not found") return {"message": "Job status updated", "file_id": str(updated_file.id), "job_status": updated_file.job_status} @@ -107,8 +83,7 @@ def get_file_status(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): if not file: raise HTTPException(status_code=404, detail="File not found") - ocr_result = update_ocr_job_status(file=file, session=session, user=user) # Poll OCR API for latest status - file.job_status = ocr_result.data.state # Update file status based on OCR job state + file.job_status = get_ocr_job_status(file=file, session=session, user=user) # Poll OCR API for latest status return file @@ -127,7 +102,7 @@ def list_files(session: SessionDep, user: CurrentUser, skip: int = 0, limit: int return FilesPublic(data=files, count=len(files)) # ty:ignore[invalid-argument-type] -@router.post("/{file_id}/download") +@router.post("/{file_id}/download", response_class=Response) def download_table_excel_file(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): """ Stream an Excel file built from the OCR result JSON stored in R2. @@ -148,21 +123,6 @@ def download_table_excel_file(file_id: uuid.UUID, session: SessionDep, user: Cur headers={"Content-Disposition": content_disposition}, ) -@router.get("jobs/{job_id}/status") -def get_job_status_endpoint(job_id: str): - """ - Get the status of an OCR job by job ID. - """ - # This endpoint can be used by a background worker to poll OCR job status if needed - # For now, we handle polling in the get_file_status endpoint, but this can be useful for more direct checks - - try: - ocr_status = get_job_status(job_id=job_id) - return {"job_id": job_id, "status": ocr_status} - except Exception as exc: - raise HTTPException(status_code=500, detail=str(exc)) - - @router.post("/batch/status", response_model=FilesPublic) def get_files_batch_status( body: FilesStatusRequest, @@ -173,6 +133,7 @@ def get_files_batch_status( Accept a list of file IDs, refresh each file's OCR job status, and return the updated list of files. """ + logger.info(f"Received batch status request for file IDs: {body.file_ids} from user {user.email}") files: list[File] = [] for file_id in body.file_ids: file = session.get(File, file_id) @@ -182,8 +143,7 @@ def get_files_batch_status( raise HTTPException(status_code=403, detail=f"Not authorized to access file {file_id}") try: - ocr_result = update_ocr_job_status(file=file, session=session, user=user) - file.job_status = ocr_result.data.state + file.job_status = get_ocr_job_status(file=file, session=session, user=user) except Exception as exc: logger.error(f"Error refreshing OCR status for file {file_id}: {exc}") diff --git a/backend/app/files/service.py b/backend/app/files/service.py index 98643e19f7..6d7fc3550f 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -5,7 +5,7 @@ import pandas as pd from sqlmodel import Session -from app.aws.client import generate_presigned_put_url, upload_file_to_r2 +from app.aws.client import generate_presigned_put_url from app.aws.config import aws_settings from app.files.dependencies import CurrentUser from app.files.models import File @@ -13,22 +13,6 @@ from app.files.utils import get_df_from_result_json -def handle_uploaded_file(*, session: Session, file: File, user: CurrentUser, file_bytes: bytes) -> None: - """ - Handle a newly uploaded file by creating a File record in the database. - The actual file content is expected to be uploaded separately to the provided presigned URL. - """ - # upload to r2 - r2_result = upload_file_to_r2( - key=user.email + "/" + str(file.id) + "/" + file.filename, # Use DB record ID for unique key - data=file_bytes, - content_type=file.content_type, - presign=True - ) - - # enqueue OCR job - - def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: db_file = File.model_validate(file_in, update={"user_id": user_id}) session.add(db_file) @@ -42,7 +26,7 @@ def delete_file(*, session: Session, file_id: uuid.UUID) -> None: session.delete(db_file) session.commit() -def update_file_job_info( +def update_file_info( session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None ) -> File | None: db_file: File | None = session.get(File, file_id) @@ -68,7 +52,7 @@ def download_excel_file(file: File, user: CurrentUser) -> tuple[bytes, str]: df = get_df_from_result_json(presigned_url) output = io.BytesIO() - with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] + with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] # ty:ignore[invalid-argument-type] df.to_excel(writer, index=False, sheet_name="OCR Tables") excel_bytes = output.getvalue() diff --git a/backend/app/ocrs/schemas.py b/backend/app/ocrs/schemas.py index 3f514b22bc..e24e9384fb 100644 --- a/backend/app/ocrs/schemas.py +++ b/backend/app/ocrs/schemas.py @@ -31,6 +31,9 @@ class OcrSubmitResponse(BaseModel): msg: str | None = None data: OcrSubmitData + def is_success(self) -> bool: + return self.code == 0 + # --------------------------------------------------------------------------- # Job-level response (GET /jobs/{jobId}) @@ -50,6 +53,9 @@ class OcrJobResponse(BaseModel): msg: str | None = None data: OcrJobData + def is_success(self) -> bool: + return self.code == 0 + # --------------------------------------------------------------------------- # Batch-job response (GET /jobs/batch/{batchId}) diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 2ef613b522..7545d5b08e 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -6,7 +6,7 @@ from app.aws.client import upload_file_to_r2 from app.core.config import settings from app.files.models import File -from app.files.service import update_file_job_info +from app.files.service import update_file_info from app.ocrs.constants import OcrJobStatus from app.ocrs.dependencies import CurrentUser, SessionDep from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse @@ -15,92 +15,100 @@ logger = logging.getLogger(__name__) -def upload_ocr_job(session: Session, file: File, file_url: str) -> tuple[bool, str | None]: +headers = { + "Authorization": f"bearer {settings.OCR_API_TOKEN}", + "Content-Type": "application/json", +} + +optional_payload = { + "useDocOrientationClassify": False, + "useDocUnwarping": False, + "useChartRecognition": False, +} + +def post_ocr_jobs(session: Session, file: File, file_url: str) -> tuple[bool, str | None]: """ Submit an OCR job for the given file URL and update job_id / job_status on the File record. Only posts the job — polling is handled separately. """ - headers = { - "Authorization": f"bearer {settings.OCR_API_TOKEN}", - "Content-Type": "application/json", - } - - optional_payload = { - "useDocOrientationClassify": False, - "useDocUnwarping": False, - "useChartRecognition": False, - } - payload = { "fileUrl": file_url, "model": settings.OCR_MODEL, "optionalPayload": optional_payload, } + raw = requests.post(str(settings.OCR_JOB_URL), json=payload, headers=headers) raw.raise_for_status() - + logger.info("Submitted OCR job for file %s, response: %s", file.id, raw.text) submit_response = OcrSubmitResponse.model_validate(raw.json()) - is_success = submit_response.code == 0 + is_success = submit_response.is_success() + if not is_success: + logger.error("Failed to submit OCR job for file %s: %s - %s", file.id, submit_response.code, submit_response.msg) + return (False, None) - update_file_job_info( + job_id = submit_response.data.jobId + + update_file_info( session, - file.id, - job_status=OcrJobStatus.RUNNING if is_success else OcrJobStatus.FAILED, - job_id=submit_response.data.jobId, - err_msg=None if is_success else submit_response.msg + file_id=file.id, + job_status=OcrJobStatus.RUNNING, + job_id=job_id, + err_msg=None ) - return (is_success, submit_response.data.jobId if is_success else None) + return (is_success, job_id) -def update_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> OcrJobResponse: +def get_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> str | None: """ Poll the OCR API for job results. Returns a typed OcrJobResponse. """ + if not file.job_id: + logger.error("File %s has no job_id but is being polled for OCR status", file.id) + update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg="No job_id for this file") + raise Exception("No job_id for this file") + if file.job_status == OcrJobStatus.DONE or file.job_status == OcrJobStatus.FAILED: - logger.info("File %s already has job status %s, skipping OCR job status update", file.id, file.job_status) - return OcrJobResponse(code=0, msg="Job already completed", data=None) # ty:ignore[call-arg] # ty:ignore[invalid-argument-type] + return file.job_status headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} + raw = requests.get(f"{settings.OCR_JOB_URL}/{file.job_id}", headers=headers) + assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" + result: OcrJobResponse = OcrJobResponse.model_validate(raw.json()) - if not result.code == 0: + if not result.is_success(): logger.error("Error fetching OCR job status for job_id %s: %s", file.job_id, result.msg) - update_file_job_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.msg) - return result + update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.msg) + raise Exception(f"OCR API error: {result.msg}") state = result.data.state if state != OcrJobStatus.PENDING: - update_file_job_info(session, file_id=file.id, job_status=state) # Update all files with this job_id - - if state == OcrJobStatus.DONE: - key = f"{user.email}/{file.id}/result.json" - (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) - if json_url: - upload_file_to_r2(key=key, data=get_bytes_from_file_url(json_url), content_type="application/json") - if result.data.extractProgress: - logger.info("OCR job %s progress: %s", file.job_id, result.data.extractProgress) - increment_storage_stat( - session=session, - user_id=user.id, - size_delta=file.size, - total_pages_delta=result.data.extractProgress.extractedPages, - ) # Increment extracted pages in storage stat - logger.info("OCR job %s completed successfully. Result URLs - JSON: %s, Markdown: %s", file.job_id, json_url, md_url) + update_file_info(session, file_id=file.id, job_status=state) # Update all files with this job_id + + elif state == OcrJobStatus.DONE: + logger.info("OCR job %s completed successfully. Result uploaded to R2.", file.job_id) + upload_ocr_job_result(user=user, file=file, result=result, session=session) elif state == OcrJobStatus.FAILED: logger.error("OCR job %s failed: %s", file.job_id, result.data.errorMsg) - update_file_job_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) + update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) - return result + return state -def get_job_status(job_id: str) -> object: - """ - Get the current status of an OCR job by job ID. Returns a typed OcrJobResponse. - """ - headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} - raw = requests.get(f"{settings.OCR_JOB_URL}/{job_id}", headers=headers) - assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" - return raw.json() \ No newline at end of file +def upload_ocr_job_result(user: CurrentUser, file: File, result: OcrJobResponse, session: SessionDep): + key = f"{user.email}/{file.id}/result.json" + (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) + + if json_url: + upload_file_to_r2(key=key, data=get_bytes_from_file_url(json_url), content_type="application/json") + + increment_storage_stat( + session=session, + user_id=user.id, + size_delta=file.size, + total_pages_delta=result.data.extractProgress.extractedPages, # ty:ignore[unresolved-attribute] + file_count_delta=1 + ) \ No newline at end of file diff --git a/backend/app/storages/service.py b/backend/app/storages/service.py index de08fc41ef..b69bbcdf5e 100644 --- a/backend/app/storages/service.py +++ b/backend/app/storages/service.py @@ -40,11 +40,15 @@ def update_storage_stat( def increment_storage_stat( - *, session: Session, user_id: uuid.UUID, size_delta: int = 0, cost_delta: float = 0.0, - total_pages_delta: int = 0 + *, session: Session, + user_id: uuid.UUID, + size_delta: int = 0, + cost_delta: float = 0.0, + total_pages_delta: int = 0, + file_count_delta: int = 0, ) -> UserStorageStat: stat = get_or_create_storage_stat(session=session, user_id=user_id) - stat.file_count += 1 + stat.file_count += file_count_delta stat.total_size += size_delta stat.total_cost += cost_delta stat.total_pages += total_pages_delta diff --git a/frontend/src/routes/_layout/dashboard.tsx b/frontend/src/routes/_layout/dashboard.tsx index afabe9effb..ff853f3e9c 100644 --- a/frontend/src/routes/_layout/dashboard.tsx +++ b/frontend/src/routes/_layout/dashboard.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" import { ArrowRight } from "lucide-react" import { useState } from "react" -import { FilesService, StoragesService } from "@/client" +import { FilesService, FilesUploadFileEndpointResponse, StoragesService } from "@/client" import { FileUploadDropzone } from "@/components/FileUploadDropzone" import { useLoadingSpinner } from "@/components/loading-spinner-provider" import { Button } from "@/components/ui/button" @@ -16,6 +16,7 @@ export const Route = createFileRoute("/_layout/dashboard")({ }) function Dashboard() { + const queryClient = useQueryClient() const { showSuccessToast, showErrorToast } = useCustomToast() const { showSpinner, hideSpinner } = useLoadingSpinner() @@ -28,19 +29,22 @@ function Dashboard() { const uploadMutation = useMutation({ mutationFn: async (files: File[]) => { - for (const file of files) { - showSpinner(`Uploading "${file.name}"...`) - await FilesService.uploadFileEndpoint({ formData: { file } }) + // Upload all files in parallel using Promise.all; each promise resolves + // to an UploadResult (ok or error) so we can report partial failures. + showSpinner(`Uploading ${files.length} file(s)...`) + try { + const promises = files.map((file) => + FilesService.uploadFileEndpoint({ formData: { file } }) + ) + + const results = await Promise.all(promises) + console.log("Upload results:", results) + return results + } finally { + hideSpinner() } - hideSpinner() }, onSuccess: () => { - const count = selectedFiles.length - showSuccessToast( - count === 1 - ? "File uploaded successfully." - : `${count} files uploaded successfully.`, - ) setSelectedFiles([]) queryClient.invalidateQueries({ queryKey: ["files"] }) }, From 9e5d1c8f2cbb5660b768490481c9806a3b62d82d Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 12 Apr 2026 01:44:43 +0700 Subject: [PATCH 13/30] update changes --- backend/app/api/routes/utils.py | 61 +++++++- backend/app/files/router.py | 2 + backend/app/files/service.py | 2 + backend/app/ocrs/service.py | 12 +- frontend/biome.json | 3 + frontend/src/client/schemas.gen.ts | 141 +----------------- frontend/src/client/sdk.gen.ts | 100 ++----------- frontend/src/client/types.gen.ts | 56 +------ frontend/src/components/Auth/AuthModal.tsx | 123 +++++++++++---- frontend/src/components/Auth/LoginModal.tsx | 43 ++++-- frontend/src/components/Auth/SignupModal.tsx | 48 ++++-- frontend/src/components/FileUploadZone.tsx | 85 +++++++++++ frontend/src/components/Files/columns.tsx | 73 +++++---- .../components/UserSettings/DeleteAccount.tsx | 5 +- .../UserSettings/DeleteConfirmation.tsx | 5 +- .../components/UserSettings/MonthlyUsage.tsx | 6 +- frontend/src/components/faq.tsx | 5 +- frontend/src/components/footer.tsx | 27 +++- frontend/src/components/header.tsx | 11 +- frontend/src/components/hero.tsx | 72 ++------- frontend/src/routes/_layout/dashboard.tsx | 19 +-- frontend/src/routes/_layout/files.tsx | 3 +- frontend/src/routes/_public/index.tsx | 100 +------------ frontend/src/routes/_public/pricing.tsx | 41 +++-- frontend/src/routes/login.tsx | 7 +- 25 files changed, 499 insertions(+), 551 deletions(-) create mode 100644 frontend/src/components/FileUploadZone.tsx diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index 0c2f5e5ea6..6b7f4692b4 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -1,7 +1,12 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic.networks import EmailStr +from sqlalchemy import delete +from sqlmodel import Session -from app.auth.dependencies import get_current_active_superuser +from app.aws.client import get_s3_client +from app.aws.config import aws_settings +from app.auth.dependencies import get_current_active_superuser, get_db +from app.files.models import File from app.users.schemas import Message from app.users.utils import generate_test_email, send_email @@ -29,3 +34,55 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True + + +@router.post( + "/clear-files/", + dependencies=[Depends(get_current_active_superuser)], + status_code=200, +) +def clear_all_files(session: Session = Depends(get_db)) -> Message: + """Remove all objects from the configured R2 bucket and delete File rows. + + Requires superuser. This is a destructive operation. + """ + bucket = aws_settings.R2_BUCKET_NAME + if not bucket: + raise HTTPException(status_code=500, detail="R2 bucket not configured") + + client = get_s3_client() + + # List all objects in the bucket and delete them in batches of up to 1000 + try: + paginator_args = {"Bucket": bucket} + keys_to_delete: list[dict[str, str]] = [] + resp = client.list_objects_v2(**paginator_args) + while True: + contents = resp.get("Contents") or [] + for obj in contents: + keys_to_delete.append({"Key": obj["Key"]}) + + # If we have 1000 keys, delete them now + if len(keys_to_delete) >= 1000: + client.delete_objects(Bucket=bucket, Delete={"Objects": keys_to_delete}) + keys_to_delete = [] + + if not resp.get("IsTruncated"): + break + resp = client.list_objects_v2(Bucket=bucket, ContinuationToken=resp.get("NextContinuationToken")) + + if keys_to_delete: + client.delete_objects(Bucket=bucket, Delete={"Objects": keys_to_delete}) + except Exception as exc: # pragma: no cover - depends on external service + raise HTTPException(status_code=500, detail=f"Failed to clear R2 bucket: {exc}") + + # Delete all File rows in the database + try: + statement = delete(File) + session.exec(statement) + session.commit() + except Exception as exc: # pragma: no cover - db issue + raise HTTPException(status_code=500, detail=f"Failed to delete File records: {exc}") + + return Message(message="Cleared all objects from R2 bucket and deleted File records") + diff --git a/backend/app/files/router.py b/backend/app/files/router.py index f79b9bbbd7..5c9c26cd96 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -51,6 +51,7 @@ def upload_file_endpoint( delete_file(session=session, file_id=file_result.id) # Clean up DB record on failure raise HTTPException(status_code=500, detail="Failed to upload file to R2") + logger.info(f"File {file_result.id} uploaded to R2 successfully, URL: {r2_result['PresignedURL']}") post_ocr_jobs(session=session, file=file_result, file_url=r2_result["PresignedURL"]) return file_result @@ -137,6 +138,7 @@ def get_files_batch_status( files: list[File] = [] for file_id in body.file_ids: file = session.get(File, file_id) + logger.info(f"Processing file ID {file_id}: found file {file} in database") if not file: raise HTTPException(status_code=404, detail=f"File {file_id} not found") if file.user_id != user.id: diff --git a/backend/app/files/service.py b/backend/app/files/service.py index 6d7fc3550f..44caed5016 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -3,6 +3,7 @@ from urllib.parse import quote import pandas as pd +from alembic.autogenerate.api import log from sqlmodel import Session from app.aws.client import generate_presigned_put_url @@ -30,6 +31,7 @@ def update_file_info( session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None ) -> File | None: db_file: File | None = session.get(File, file_id) + print(f"Updating file {file_id} with job_status={job_status}, job_id={job_id}, err_msg={err_msg}") if db_file: db_file.job_status = job_status if job_id: diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 7545d5b08e..37812f471a 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -48,7 +48,7 @@ def post_ocr_jobs(session: Session, file: File, file_url: str) -> tuple[bool, st return (False, None) job_id = submit_response.data.jobId - + logger.info("OCR job submitted successfully for file %s, job_id: %s", file.id, job_id) update_file_info( session, file_id=file.id, @@ -78,18 +78,20 @@ def get_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> st assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" result: OcrJobResponse = OcrJobResponse.model_validate(raw.json()) - + logger.info("get_ocr_job_status for file %s,\n result: %s", file.json(), result.json()) if not result.is_success(): logger.error("Error fetching OCR job status for job_id %s: %s", file.job_id, result.msg) update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.msg) raise Exception(f"OCR API error: {result.msg}") state = result.data.state - if state != OcrJobStatus.PENDING: - update_file_info(session, file_id=file.id, job_status=state) # Update all files with this job_id + logger.info("OCR job %s status: %s", file.job_id, state) + if state == OcrJobStatus.RUNNING and file.job_status == OcrJobStatus.PENDING: + update_file_info(session, file_id=file.id, job_status=OcrJobStatus.RUNNING) # Update all files with this job_id elif state == OcrJobStatus.DONE: logger.info("OCR job %s completed successfully. Result uploaded to R2.", file.job_id) + update_file_info(session, file_id=file.id, job_status=OcrJobStatus.DONE) upload_ocr_job_result(user=user, file=file, result=result, session=session) elif state == OcrJobStatus.FAILED: @@ -101,7 +103,7 @@ def get_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> st def upload_ocr_job_result(user: CurrentUser, file: File, result: OcrJobResponse, session: SessionDep): key = f"{user.email}/{file.id}/result.json" (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) - + logger.info(f"Uploading OCR job result for file {file.id} to R2, json_url: {json_url}, md_url: {md_url}") if json_url: upload_file_to_r2(key=key, data=get_bytes_from_file_url(json_url), content_type="application/json") diff --git a/frontend/biome.json b/frontend/biome.json index d24f85d2c2..7fbed4d19c 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -26,6 +26,9 @@ "noParameterAssign": "error", "useSelfClosingElements": "error", "noUselessElse": "error" + }, + "a11y": { + "useKeyWithClickEvents": "error" } } }, diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 560e1356b4..ac7025f0b4 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -351,81 +351,6 @@ export const NewPasswordSchema = { title: 'NewPassword' } as const; -export const PresignRequestSchema = { - properties: { - filename: { - type: 'string', - title: 'Filename' - }, - content_type: { - anyOf: [ - { - type: 'string' - }, - { - type: 'null' - } - ], - title: 'Content Type' - }, - bucket: { - anyOf: [ - { - type: 'string' - }, - { - type: 'null' - } - ], - title: 'Bucket' - } - }, - type: 'object', - required: ['filename'], - title: 'PresignRequest' -} as const; - -export const PresignResponseSchema = { - properties: { - url: { - type: 'string', - title: 'Url' - }, - key: { - type: 'string', - title: 'Key' - } - }, - type: 'object', - required: ['url', 'key'], - title: 'PresignResponse' -} as const; - -export const PrivateUserCreateSchema = { - properties: { - email: { - type: 'string', - title: 'Email' - }, - password: { - type: 'string', - title: 'Password' - }, - full_name: { - type: 'string', - title: 'Full Name' - }, - is_verified: { - type: 'boolean', - title: 'Is Verified', - default: false - } - }, - type: 'object', - required: ['email', 'password', 'full_name'], - title: 'PrivateUserCreate' -} as const; - export const TokenSchema = { properties: { access_token: { @@ -621,7 +546,7 @@ export const UserStorageStatPublicSchema = { type: 'integer', title: 'Total Transactions' }, - extracted_pages: { + total_pages: { anyOf: [ { type: 'integer' @@ -630,7 +555,7 @@ export const UserStorageStatPublicSchema = { type: 'null' } ], - title: 'Extracted Pages' + title: 'Total Pages' } }, type: 'object', @@ -638,68 +563,6 @@ export const UserStorageStatPublicSchema = { title: 'UserStorageStatPublic' } as const; -export const UserStorageStatUpdateSchema = { - properties: { - file_count: { - anyOf: [ - { - type: 'integer' - }, - { - type: 'null' - } - ], - title: 'File Count' - }, - total_size: { - anyOf: [ - { - type: 'integer' - }, - { - type: 'null' - } - ], - title: 'Total Size' - }, - total_cost: { - anyOf: [ - { - type: 'number' - }, - { - type: 'null' - } - ], - title: 'Total Cost' - }, - total_transactions: { - anyOf: [ - { - type: 'integer' - }, - { - type: 'null' - } - ], - title: 'Total Transactions' - }, - extracted_pages: { - anyOf: [ - { - type: 'integer' - }, - { - type: 'null' - } - ], - title: 'Extracted Pages' - } - }, - type: 'object', - title: 'UserStorageStatUpdate' -} as const; - export const UserUpdateSchema = { properties: { email: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 2dbec588f3..b69147fef5 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesPresignUploadData, FilesPresignUploadResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesGetJobStatusEndpointData, FilesGetJobStatusEndpointResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, StoragesGetMyStorageStatResponse, StoragesUpdateMyStorageStatData, StoragesUpdateMyStorageStatResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; export class FilesService { /** @@ -49,26 +49,6 @@ export class FilesService { }); } - /** - * Presign Upload - * Generate a presigned PUT URL for direct client uploads. - * @param data The data for the request. - * @param data.requestBody - * @returns PresignResponse Successful Response - * @throws ApiError - */ - public static presignUpload(data: FilesPresignUploadData): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/files/presign', - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } - /** * Update File Job Status Endpoint * Update the job status for a file based on OCR job updates. @@ -134,27 +114,6 @@ export class FilesService { }); } - /** - * Get Job Status Endpoint - * Get the status of an OCR job by job ID. - * @param data The data for the request. - * @param data.jobId - * @returns unknown Successful Response - * @throws ApiError - */ - public static getJobStatusEndpoint(data: FilesGetJobStatusEndpointData): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/filesjobs/{job_id}/status', - path: { - job_id: data.jobId - }, - errors: { - 422: 'Validation Error' - } - }); - } - /** * Get Files Batch Status * Accept a list of file IDs, refresh each file's OCR job status, @@ -385,28 +344,6 @@ export class LoginService { } } -export class PrivateService { - /** - * Create User - * Create a new user. - * @param data The data for the request. - * @param data.requestBody - * @returns UserPublic Successful Response - * @throws ApiError - */ - public static createUser(data: PrivateCreateUserData): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/v1/private/users/', - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } -} - export class StoragesService { /** * Get My Storage Stat @@ -420,26 +357,6 @@ export class StoragesService { url: '/api/v1/storages/me' }); } - - /** - * Update My Storage Stat - * Update the storage statistics for the current user. - * @param data The data for the request. - * @param data.requestBody - * @returns UserStorageStatPublic Successful Response - * @throws ApiError - */ - public static updateMyStorageStat(data: StoragesUpdateMyStorageStatData): CancelablePromise { - return __request(OpenAPI, { - method: 'PATCH', - url: '/api/v1/storages/me', - body: data.requestBody, - mediaType: 'application/json', - errors: { - 422: 'Validation Error' - } - }); - } } export class UsersService { @@ -672,4 +589,19 @@ export class UtilsService { url: '/api/v1/utils/health-check/' }); } + + /** + * Clear All Files + * Remove all objects from the configured R2 bucket and delete File rows. + * + * Requires superuser. This is a destructive operation. + * @returns Message Successful Response + * @throws ApiError + */ + public static clearAllFiles(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/utils/clear-files/' + }); + } } \ No newline at end of file diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 058ae3f657..7036c6d95e 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -69,24 +69,6 @@ export type NewPassword = { new_password: string; }; -export type PresignRequest = { - filename: string; - content_type?: (string | null); - bucket?: (string | null); -}; - -export type PresignResponse = { - url: string; - key: string; -}; - -export type PrivateUserCreate = { - email: string; - password: string; - full_name: string; - is_verified?: boolean; -}; - export type Token = { access_token: string; token_type?: string; @@ -133,15 +115,7 @@ export type UserStorageStatPublic = { total_cost: number; updated_at: string; total_transactions: number; - extracted_pages?: (number | null); -}; - -export type UserStorageStatUpdate = { - file_count?: (number | null); - total_size?: (number | null); - total_cost?: (number | null); - total_transactions?: (number | null); - extracted_pages?: (number | null); + total_pages?: (number | null); }; export type UserUpdate = { @@ -180,12 +154,6 @@ export type FilesListFilesData = { export type FilesListFilesResponse = (FilesPublic); -export type FilesPresignUploadData = { - requestBody: PresignRequest; -}; - -export type FilesPresignUploadResponse = (PresignResponse); - export type FilesUpdateFileJobStatusEndpointData = { fileId: string; jobStatus: string; @@ -205,12 +173,6 @@ export type FilesDownloadTableExcelFileData = { export type FilesDownloadTableExcelFileResponse = (unknown); -export type FilesGetJobStatusEndpointData = { - jobId: string; -}; - -export type FilesGetJobStatusEndpointResponse = (unknown); - export type FilesGetFilesBatchStatusData = { requestBody: FilesStatusRequest; }; @@ -275,20 +237,8 @@ export type LoginRecoverPasswordHtmlContentData = { export type LoginRecoverPasswordHtmlContentResponse = (string); -export type PrivateCreateUserData = { - requestBody: PrivateUserCreate; -}; - -export type PrivateCreateUserResponse = (UserPublic); - export type StoragesGetMyStorageStatResponse = (UserStorageStatPublic); -export type StoragesUpdateMyStorageStatData = { - requestBody: UserStorageStatUpdate; -}; - -export type StoragesUpdateMyStorageStatResponse = (UserStorageStatPublic); - export type UsersReadUsersData = { limit?: number; skip?: number; @@ -349,4 +299,6 @@ export type UtilsTestEmailData = { export type UtilsTestEmailResponse = (Message); -export type UtilsHealthCheckResponse = (boolean); \ No newline at end of file +export type UtilsHealthCheckResponse = (boolean); + +export type UtilsClearAllFilesResponse = (Message); \ No newline at end of file diff --git a/frontend/src/components/Auth/AuthModal.tsx b/frontend/src/components/Auth/AuthModal.tsx index 4636211e6c..f12d143810 100644 --- a/frontend/src/components/Auth/AuthModal.tsx +++ b/frontend/src/components/Auth/AuthModal.tsx @@ -1,16 +1,18 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { useMutation } from "@tanstack/react-query" +import { useState } from "react" import { useForm } from "react-hook-form" import { z } from "zod" import type { Body_login_login_access_token as AccessToken } from "@/client" -import useAuth from "@/hooks/useAuth" -import { useState } from "react" -import { useMutation } from "@tanstack/react-query" import { LoginService } from "@/client" -import useCustomToast from "@/hooks/useCustomToast" -import { handleError } from "@/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Form, FormControl, @@ -22,6 +24,10 @@ import { import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" import { PasswordInput } from "@/components/ui/password-input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import useAuth from "@/hooks/useAuth" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" const signInSchema = z.object({ username: z.string().email(), @@ -65,7 +71,12 @@ export default function AuthModal({ const signUpForm = useForm({ resolver: zodResolver(signUpSchema), mode: "onBlur", - defaultValues: { email: "", full_name: "", password: "", confirm_password: "" }, + defaultValues: { + email: "", + full_name: "", + password: "", + confirm_password: "", + }, }) const recoverForm = useForm<{ email: string }>({ @@ -75,7 +86,8 @@ export default function AuthModal({ }) const recoverMutation = useMutation({ - mutationFn: (data: { email: string }) => LoginService.recoverPassword({ email: data.email }), + mutationFn: (data: { email: string }) => + LoginService.recoverPassword({ email: data.email }), onSuccess: () => { showSuccessToast("If that email exists, a recovery message has been sent") setTab("signin") @@ -103,13 +115,20 @@ export default function AuthModal({ - Welcome to docs2excel.ai + + Welcome to docs2excel.ai + Log in and subscribe to unlock advanced features - setTab(v as "signin" | "signup" | "recover")}> + + setTab(v as "signin" | "signup" | "recover") + } + > Sign In Sign Up @@ -117,7 +136,10 @@ export default function AuthModal({
    - +
    Email - + @@ -140,19 +166,29 @@ export default function AuthModal({
    Password -
    - +
    )} /> - + Sign In
    @@ -162,7 +198,10 @@ export default function AuthModal({ - +
    Email - + @@ -213,20 +256,33 @@ export default function AuthModal({ Confirm Password - + )} /> - + Sign Up
    - Already have an account? + Already have an account?{" "} +
    @@ -234,7 +290,10 @@ export default function AuthModal({
    - +
    Email - + )} /> - + Send Recovery Email
    - Remembered your password? + Remembered your password?{" "} +
    diff --git a/frontend/src/components/Auth/LoginModal.tsx b/frontend/src/components/Auth/LoginModal.tsx index f92f3a1f5f..9b725d0e60 100644 --- a/frontend/src/components/Auth/LoginModal.tsx +++ b/frontend/src/components/Auth/LoginModal.tsx @@ -1,10 +1,17 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" +import { Link as RouterLink } from "@tanstack/react-router" import { useForm } from "react-hook-form" import { z } from "zod" import type { Body_login_login_access_token as AccessToken } from "@/client" -import useAuth from "@/hooks/useAuth" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { Form, FormControl, @@ -16,7 +23,7 @@ import { import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" import { PasswordInput } from "@/components/ui/password-input" -import { Link as RouterLink } from "@tanstack/react-router" +import useAuth from "@/hooks/useAuth" const formSchema = z.object({ username: z.string().email(), @@ -46,11 +53,16 @@ export default function LoginModal({ trigger }: { trigger: React.ReactNode }) { Welcome back - Log in to unlock advanced features + + Log in to unlock advanced features +
    - +
    Email - + @@ -73,12 +89,18 @@ export default function LoginModal({ trigger }: { trigger: React.ReactNode }) {
    Password - + Forgot your password?
    - +
    @@ -91,7 +113,10 @@ export default function LoginModal({ trigger }: { trigger: React.ReactNode }) {
    - Don't have an account? Sign up + Don't have an account?{" "} + + Sign up +
    diff --git a/frontend/src/components/Auth/SignupModal.tsx b/frontend/src/components/Auth/SignupModal.tsx index 28e991ed63..28e349860f 100644 --- a/frontend/src/components/Auth/SignupModal.tsx +++ b/frontend/src/components/Auth/SignupModal.tsx @@ -1,9 +1,16 @@ import { zodResolver } from "@hookform/resolvers/zod" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" +import { Link as RouterLink } from "@tanstack/react-router" +import type React from "react" import { useForm } from "react-hook-form" import { z } from "zod" - -import useAuth from "@/hooks/useAuth" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" import { Form, FormControl, @@ -15,8 +22,7 @@ import { import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" import { PasswordInput } from "@/components/ui/password-input" -import { Link as RouterLink } from "@tanstack/react-router" -import type React from "react" +import useAuth from "@/hooks/useAuth" const formSchema = z .object({ @@ -39,7 +45,12 @@ export default function SignupModal({ trigger }: { trigger: React.ReactNode }) { resolver: zodResolver(formSchema), mode: "onBlur", criteriaMode: "all", - defaultValues: { email: "", full_name: "", password: "", confirm_password: "" }, + defaultValues: { + email: "", + full_name: "", + password: "", + confirm_password: "", + }, }) const onSubmit = (data: FormData) => { @@ -54,11 +65,16 @@ export default function SignupModal({ trigger }: { trigger: React.ReactNode }) { Create an account - Sign up to start your free trial + + Sign up to start your free trial +
    - +
    Email - + @@ -109,7 +129,10 @@ export default function SignupModal({ trigger }: { trigger: React.ReactNode }) { Confirm Password - + @@ -122,7 +145,10 @@ export default function SignupModal({ trigger }: { trigger: React.ReactNode }) {
    - Already have an account? Log in + Already have an account?{" "} + + Log in +
    diff --git a/frontend/src/components/FileUploadZone.tsx b/frontend/src/components/FileUploadZone.tsx new file mode 100644 index 0000000000..647c70dc3e --- /dev/null +++ b/frontend/src/components/FileUploadZone.tsx @@ -0,0 +1,85 @@ +import type React from "react" +import { useRef, useState } from "react" + +type Props = { + onFileSelect?: (file: File) => void + onClick?: () => void + accept?: string + className?: string + title?: string + description?: string + sizeHint?: string +} + +export default function FileUploadZone({ + onFileSelect, + onClick, + accept = ".pdf,.jpg,.jpeg,.png,.gif,.bmp", + className, + title = "Drag and drop your bank statement here", + description = "PDF or image (JPG, PNG)", + sizeHint = "Size up to 100 MB", +}: Props) { + const [isDrag, setIsDrag] = useState(false) + const fileInputRef = useRef(null) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDrag(true) + } + + const handleDragLeave = () => { + setIsDrag(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDrag(false) + const files = e.dataTransfer.files + if (files.length > 0) { + const file = files[0] + const isValidType = file.type === "application/pdf" || file.type.startsWith("image/") + if (isValidType) { + onFileSelect?.(file) + } + } + } + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + onFileSelect?.(files[0]) + } + } + + return ( +
    + + +
    + ) +} diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index c53b74a6a2..d1b161626a 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -6,6 +6,12 @@ import { FaFileExcel } from "react-icons/fa6" import { type FilePublic, FilesService } from "@/client" import { OpenAPI } from "@/client/core/OpenAPI" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" @@ -40,38 +46,52 @@ async function downloadExcel(fileId: string, filename: string) { URL.revokeObjectURL(url) } -function DownloadButton({ file }: { file: FilePublic }) { +function DownloadMenu({ file }: { file: FilePublic }) { const [loading, setLoading] = useState(false) - const handleDownload = async () => { - setLoading(true) - try { - await downloadExcel(file.id, file.filename) - } catch (err) { - console.error(err) - } finally { - setLoading(false) + const handleSelect = async (format: string) => { + if (format === "excel") { + setLoading(true) + try { + await downloadExcel(file.id, file.filename) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + return } + + // TODO: implement CSV/DOCX generation server-side or client-side conversion + console.warn(`Download format '${format}' not supported yet for file ${file.id}`) } return ( - + + + + + + void handleSelect("excel")}>Excel (.xlsx) + void handleSelect("csv")}> + CSV (.csv) — not supported + + void handleSelect("docx")}>DOCX (.docx) — not supported + + ) } + + export const columns: ColumnDef[] = [ { accessorKey: "filename", @@ -146,12 +166,7 @@ export const columns: ColumnDef[] = [ )} {file.job_status === "done" && ( - <> -
    - -
    - - + )}
    ) diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx index 40c0824bff..cca2f7284e 100644 --- a/frontend/src/components/UserSettings/DeleteAccount.tsx +++ b/frontend/src/components/UserSettings/DeleteAccount.tsx @@ -19,7 +19,10 @@ const DeleteAccount = () => {
  • - + This action cannot be undone diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx index 08bd53153f..1ec45158fa 100644 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ b/frontend/src/components/UserSettings/DeleteConfirmation.tsx @@ -43,7 +43,10 @@ const DeleteConfirmation = () => { return ( - diff --git a/frontend/src/components/UserSettings/MonthlyUsage.tsx b/frontend/src/components/UserSettings/MonthlyUsage.tsx index 14a8d9bc88..cbbc733aa4 100644 --- a/frontend/src/components/UserSettings/MonthlyUsage.tsx +++ b/frontend/src/components/UserSettings/MonthlyUsage.tsx @@ -24,7 +24,7 @@ const MonthlyUsage = () => { queryFn: () => StoragesService.getMyStorageStat(), }) - const usedPages = storageStat?.extracted_pages ?? 0 + const usedPages = storageStat?.total_pages ?? 0 const percentUsed = Math.min((usedPages / FREE_TIER_PAGE_LIMIT) * 100, 100) const resetDate = getNextMonthReset() @@ -59,9 +59,7 @@ const MonthlyUsage = () => {
    -

    - Resets on {resetDate} -

    +

    Resets on {resetDate}

    ) diff --git a/frontend/src/components/faq.tsx b/frontend/src/components/faq.tsx index cbdd9d554f..daa04859cd 100644 --- a/frontend/src/components/faq.tsx +++ b/frontend/src/components/faq.tsx @@ -51,21 +51,20 @@ export default function FAQ() { {faqs.map((faq, index) => ( - key={index} + key={faq.question} value={`item-${index}`} className="rounded-lg border border-border bg-background overflow-hidden" > {faq.question} - {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + Toggle answer PDF Guru
    -

    Convert your PDFs with confidence

    +

    + Convert your PDFs with confidence +

    @@ -136,7 +138,7 @@ export default function Footer() {
    -
    +

    © {new Date().getFullYear()} PDF Guru. All rights reserved.

    @@ -146,7 +148,12 @@ export default function Footer() { aria-label="Facebook" className="text-muted-foreground hover:text-foreground transition-colors" > - + Facebook @@ -156,7 +163,12 @@ export default function Footer() { aria-label="Twitter" className="text-muted-foreground hover:text-foreground transition-colors" > - + Twitter @@ -166,7 +178,12 @@ export default function Footer() { aria-label="Instagram" className="text-muted-foreground hover:text-foreground transition-colors" > - + Instagram diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx index 60b3f6fc8f..8238624e41 100644 --- a/frontend/src/components/header.tsx +++ b/frontend/src/components/header.tsx @@ -1,10 +1,10 @@ import { Link } from "@tanstack/react-router" import { ArrowRight } from "lucide-react" +import { useState } from "react" +import AuthModal from "@/components/Auth/AuthModal" import { isLoggedIn } from "@/hooks/useAuth" import { Appearance } from "./Common/Appearance" import { Logo } from "./Common/Logo" -import AuthModal from "@/components/Auth/AuthModal" -import { useState } from "react" export default function Header() { const [authOpen, setAuthOpen] = useState(false) @@ -44,7 +44,6 @@ export default function Header() { > FAQ
    - {/* Right — Actions */} @@ -73,7 +72,11 @@ export default function Header() { > Sign Up - + ) : ( (null) + const [isDrag] = useState(false) const navigate = useNavigate() const requireAuth = (action: () => void) => { @@ -16,25 +15,15 @@ export default function Hero() { action() } - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - setIsDrag(true) - } - - const handleDragLeave = () => { - setIsDrag(false) - } - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - setIsDrag(false) + const handleClick = () => { requireAuth(() => { navigate({ to: "/dashboard" }) }) } - const handleClick = () => { + const handleFileSelect = (_file: File) => { requireAuth(() => { + // navigate to dashboard where the selected file can be handled/uploaded navigate({ to: "/dashboard" }) }) } @@ -52,63 +41,30 @@ export default function Hero() {

    - {/** biome-ignore lint/a11y/noStaticElementInteractions: */} - {/** biome-ignore lint/a11y/useKeyWithClickEvents: */} -
    -
    - {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} - - - -
    -

    - Drag and drop your bank statement -

    -

    - or click to browse -

    -

    - Supports PDF and images (JPG, PNG) up to 100 MB -

    -
    -
    - {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + Instant Conversion
    - {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + Bank-Grade Security
    - {/** biome-ignore lint/a11y/noSvgWithoutTitle: */} + 100% Accurate ([]) @@ -34,7 +33,7 @@ function Dashboard() { showSpinner(`Uploading ${files.length} file(s)...`) try { const promises = files.map((file) => - FilesService.uploadFileEndpoint({ formData: { file } }) + FilesService.uploadFileEndpoint({ formData: { file } }), ) const results = await Promise.all(promises) @@ -160,9 +159,9 @@ function Dashboard() {
    - Total Transactions + Total Pages - {storageStat?.total_transactions?.toLocaleString() ?? "—"} + {storageStat?.total_pages?.toLocaleString() ?? "—"}
    @@ -181,14 +180,6 @@ function Dashboard() {
    - - {/* -

    Upgrade to Pro

    -

    - Get unlimited conversions and advanced features -

    - -
    */}
    diff --git a/frontend/src/routes/_layout/files.tsx b/frontend/src/routes/_layout/files.tsx index 9f9c7a4e0c..357df48fdc 100644 --- a/frontend/src/routes/_layout/files.tsx +++ b/frontend/src/routes/_layout/files.tsx @@ -12,7 +12,7 @@ function getFilesQueryOptions(limit = 0) { return { queryFn: () => FilesService.listFiles({ skip: 0, limit }), queryKey: ["files"], - refetchInterval: 3000, // Refetch every 5 seconds to get real-time updates + //refetchInterval: 3000, } } @@ -39,7 +39,6 @@ export function FilesTableContent({ limit = 0 }: { limit?: number }) { ) if (pendingFiles.length === 0) { - console.log("No pending files, stopping polling.") if (pollingRef.current) { clearInterval(pollingRef.current) pollingRef.current = null diff --git a/frontend/src/routes/_public/index.tsx b/frontend/src/routes/_public/index.tsx index ba1a2a261e..9c253fe49e 100644 --- a/frontend/src/routes/_public/index.tsx +++ b/frontend/src/routes/_public/index.tsx @@ -2,14 +2,16 @@ import * as Dialog from "@radix-ui/react-dialog" import { createFileRoute } from "@tanstack/react-router" -import { useRef, useState } from "react" +import { useState } from "react" import FAQ from "@/components/faq" import Features from "@/components/features" import Footer from "@/components/footer" import Header from "@/components/header" import Hero from "@/components/hero" +import FileUploadZone from "@/components/FileUploadZone" import HowItWorks from "@/components/how-it-works" import Testimonials from "@/components/testimonials" +import { Button } from "@/components/ui/button" export const Route = createFileRoute("/_public/")({ component: Home, @@ -42,7 +44,7 @@ function Home() { (up to 100 MB) - setFileName(name)} /> + setFileName(file.name)} /> {fileName && (
    @@ -52,26 +54,25 @@ function Home() { {fileName}

    - {/** biome-ignore lint/a11y/useButtonType: */} - +
    )} - {/** biome-ignore lint/a11y/useButtonType: */} - +
    ) } diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index d1b161626a..eab504f514 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -2,7 +2,6 @@ import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" import { DownloadIcon, Loader2, RefreshCcw } from "lucide-react" import { useState } from "react" -import { FaFileExcel } from "react-icons/fa6" import { type FilePublic, FilesService } from "@/client" import { OpenAPI } from "@/client/core/OpenAPI" import { Button } from "@/components/ui/button" @@ -16,14 +15,14 @@ import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" -async function downloadExcel(fileId: string, filename: string) { +async function downloadFile(fileId: string, filename: string, type: string) { const token = typeof OpenAPI.TOKEN === "function" ? await OpenAPI.TOKEN({} as never) : OpenAPI.TOKEN const base = OpenAPI.BASE || "" - const response = await fetch(`${base}/api/v1/files/${fileId}/download`, { + const response = await fetch(`${base}/api/v1/files/${fileId}/download?type=${type}`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -39,7 +38,37 @@ async function downloadExcel(fileId: string, filename: string) { const a = document.createElement("a") const safeName = filename.replace(/\.[^.]+$/, "") a.href = url - a.download = `${safeName}_tables.xlsx` + a.download = `${safeName}_tables.${type}` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +async function downloadNewVersion(fileId: string, filename: string) { + const token = + typeof OpenAPI.TOKEN === "function" + ? await OpenAPI.TOKEN({} as never) + : OpenAPI.TOKEN + + const base = OpenAPI.BASE || "" + const response = await fetch(`${base}/api/v1/files/${fileId}/download/new`, { + method: "POST", + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) + + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`) + } + + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + const safeName = filename.replace(/\.[^.]+$/, "") + a.href = url + a.download = `${safeName}_tables_new.xlsx` document.body.appendChild(a) a.click() a.remove() @@ -50,20 +79,14 @@ function DownloadMenu({ file }: { file: FilePublic }) { const [loading, setLoading] = useState(false) const handleSelect = async (format: string) => { - if (format === "excel") { - setLoading(true) - try { - await downloadExcel(file.id, file.filename) - } catch (err) { - console.error(err) - } finally { - setLoading(false) - } - return + setLoading(true) + try { + await downloadFile(file.id, file.filename, format) + } catch (err) { + console.error(err) + } finally { + setLoading(false) } - - // TODO: implement CSV/DOCX generation server-side or client-side conversion - console.warn(`Download format '${format}' not supported yet for file ${file.id}`) } return ( @@ -76,21 +99,67 @@ function DownloadMenu({ file }: { file: FilePublic }) { title="Download" disabled={loading} > - {loading ? : } + {loading ? ( + + ) : ( + + )} - void handleSelect("excel")}>Excel (.xlsx) + void handleSelect("xlsx")}> + Excel (.xlsx) + void handleSelect("csv")}> - CSV (.csv) — not supported + CSV (.csv) + + + void handleSelect("json")}> + JSON (.json) + + + void handleSelect("html")}> + HTML (.html) + + void handleSelect("docx")}> + DOCX (.docx) — not supported - void handleSelect("docx")}>DOCX (.docx) — not supported ) } +function DownloadNewButton({ file }: { file: FilePublic }) { + const [loading, setLoading] = useState(false) + + const handleClick = async () => { + setLoading(true) + try { + await downloadNewVersion(file.id, file.filename) + } catch (err) { + console.error(err) + } finally { + setLoading(false) + } + } + return ( + + ) +} export const columns: ColumnDef[] = [ { @@ -166,7 +235,10 @@ export const columns: ColumnDef[] = [ )} {file.job_status === "done" && ( - + <> + + + )}
    ) diff --git a/frontend/src/routes/_public/index.tsx b/frontend/src/routes/_public/index.tsx index 9c253fe49e..5f2c5e5b82 100644 --- a/frontend/src/routes/_public/index.tsx +++ b/frontend/src/routes/_public/index.tsx @@ -1,14 +1,12 @@ -/** biome-ignore-all lint/a11y/noStaticElementInteractions: */ - import * as Dialog from "@radix-ui/react-dialog" import { createFileRoute } from "@tanstack/react-router" import { useState } from "react" +import FileUploadZone from "@/components/FileUploadZone" import FAQ from "@/components/faq" import Features from "@/components/features" import Footer from "@/components/footer" import Header from "@/components/header" import Hero from "@/components/hero" -import FileUploadZone from "@/components/FileUploadZone" import HowItWorks from "@/components/how-it-works" import Testimonials from "@/components/testimonials" import { Button } from "@/components/ui/button" From b37e7e6c83062496e0f185c55ce293ad93788bf9 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Mon, 13 Apr 2026 07:50:09 +0700 Subject: [PATCH 15/30] changes --- .../a24416477b07_create_api_keys_table.py | 39 +++ .../c4f659581040_add_api_key_model.py | 39 +++ backend/app/api/main.py | 2 + backend/app/api_keys/__init__.py | 6 + backend/app/api_keys/crud.py | 44 ++++ backend/app/api_keys/models.py | 21 ++ backend/app/api_keys/router.py | 40 ++++ backend/app/api_keys/schemas.py | 21 ++ backend/app/core/db.py | 2 + backend/app/files/crud.py | 36 +++ backend/app/files/models.py | 2 +- backend/app/files/router.py | 54 +---- backend/app/files/service.py | 123 ++++------ backend/app/files/strategies.py | 88 +++++++ backend/app/files/utils.py | 9 +- backend/app/models.py | 1 + backend/app/ocrs/service.py | 2 +- bun.lock | 109 +++------ frontend/package.json | 3 + frontend/src/client/schemas.gen.ts | 78 ++++++ frontend/src/client/sdk.gen.ts | 57 ++++- frontend/src/client/types.gen.ts | 30 +++ .../components/Common/LanguageSwitcher.tsx | 49 ++++ frontend/src/components/Files/columns.tsx | 133 ++--------- .../src/components/Sidebar/AppSidebar.tsx | 3 +- frontend/src/components/faq.tsx | 70 +++--- frontend/src/components/features.tsx | 78 +++--- frontend/src/components/footer.tsx | 35 +-- frontend/src/components/header.tsx | 18 +- frontend/src/components/hero.tsx | 31 +-- frontend/src/components/how-it-works.tsx | 50 ++-- frontend/src/components/testimonials.tsx | 60 ++--- frontend/src/hooks/useDownloadFile.ts | 70 ++++++ frontend/src/i18n/index.ts | 44 ++++ frontend/src/i18n/locales/en/common.json | 141 +++++++++++ frontend/src/i18n/locales/vi/common.json | 141 +++++++++++ frontend/src/main.tsx | 1 + frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/_layout/api-keys.tsx | 225 ++++++++++++++++++ test.py | 9 + 40 files changed, 1495 insertions(+), 490 deletions(-) create mode 100644 backend/app/alembic/versions/a24416477b07_create_api_keys_table.py create mode 100644 backend/app/alembic/versions/c4f659581040_add_api_key_model.py create mode 100644 backend/app/api_keys/__init__.py create mode 100644 backend/app/api_keys/crud.py create mode 100644 backend/app/api_keys/models.py create mode 100644 backend/app/api_keys/router.py create mode 100644 backend/app/api_keys/schemas.py create mode 100644 backend/app/files/crud.py create mode 100644 backend/app/files/strategies.py create mode 100644 frontend/src/components/Common/LanguageSwitcher.tsx create mode 100644 frontend/src/hooks/useDownloadFile.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en/common.json create mode 100644 frontend/src/i18n/locales/vi/common.json create mode 100644 frontend/src/routes/_layout/api-keys.tsx create mode 100644 test.py diff --git a/backend/app/alembic/versions/a24416477b07_create_api_keys_table.py b/backend/app/alembic/versions/a24416477b07_create_api_keys_table.py new file mode 100644 index 0000000000..bc0bb6d5af --- /dev/null +++ b/backend/app/alembic/versions/a24416477b07_create_api_keys_table.py @@ -0,0 +1,39 @@ +"""create_api_keys_table + +Revision ID: a24416477b07 +Revises: c4f659581040 +Create Date: 2026-04-12 15:14:24.777302 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'a24416477b07' +down_revision = 'c4f659581040' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('api_keys', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys') + op.drop_table('api_keys') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/c4f659581040_add_api_key_model.py b/backend/app/alembic/versions/c4f659581040_add_api_key_model.py new file mode 100644 index 0000000000..8cdd1109d2 --- /dev/null +++ b/backend/app/alembic/versions/c4f659581040_add_api_key_model.py @@ -0,0 +1,39 @@ +"""Add api key model + +Revision ID: c4f659581040 +Revises: b27c541d6090 +Create Date: 2026-04-12 13:44:09.876423 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'c4f659581040' +down_revision = 'b27c541d6090' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('files', 'size', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('user_storage_stats', 'total_pages', + existing_type=sa.INTEGER(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user_storage_stats', 'total_pages', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('files', 'size', + existing_type=sa.INTEGER(), + nullable=True) + # ### end Alembic commands ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 021cedcfd1..7b23e448f0 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from app.api.routes.utils import router as utils_router +from app.api_keys.router import router as api_keys_router from app.auth.router import router as login_router from app.core.config import settings from app.files.router import router as files_router @@ -15,6 +16,7 @@ api_router.include_router(items_router) api_router.include_router(files_router) api_router.include_router(storages_router) +api_router.include_router(api_keys_router) if settings.ENVIRONMENT == "local": from app.api.routes.private import router as private_router diff --git a/backend/app/api_keys/__init__.py b/backend/app/api_keys/__init__.py new file mode 100644 index 0000000000..055bdc2e90 --- /dev/null +++ b/backend/app/api_keys/__init__.py @@ -0,0 +1,6 @@ +"""API Keys package + +Provides models, schemas, service and router for storing user API keys. +""" + +__all__ = ["models", "schemas", "service", "router"] diff --git a/backend/app/api_keys/crud.py b/backend/app/api_keys/crud.py new file mode 100644 index 0000000000..6b04b90e3f --- /dev/null +++ b/backend/app/api_keys/crud.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from app.backend_pre_start import logger + +import uuid +from typing import List + +from sqlmodel import Session, select + +from app.api_keys.models import ApiKey +from app.api_keys.schemas import ApiKeyCreate + + +def create_api_key(session: Session, user_id: uuid.UUID, api_key_in: ApiKeyCreate) -> ApiKey: + api_key = ApiKey(user_id=user_id, name=api_key_in.name, key=api_key_in.key) + session.add(api_key) + session.commit() + session.refresh(api_key) + return api_key + + +def get_api_keys_for_user(session: Session, user_id: uuid.UUID) -> List[ApiKey]: + statement = select(ApiKey).where(ApiKey.user_id == user_id) + return list(session.exec(statement).all()) + + +def get_api_key(session: Session, api_key_id: uuid.UUID) -> ApiKey | None: + return session.get(ApiKey, api_key_id) + + +def delete_api_key(session: Session, api_key_id: uuid.UUID) -> None: + api_key = session.get(ApiKey, api_key_id) + if api_key: + session.delete(api_key) + session.commit() + +def get_api_key_by_user(session: Session, user_id: uuid.UUID) -> ApiKey: + statement = select(ApiKey).where(ApiKey.user_id == user_id) + api_key = session.exec(statement).first() + + if not api_key: + logger.error(f"No API key found for user_id: {user_id}") + raise ValueError("No API key found! You need to create one Google Gemini API key to use this function.") + + return api_key \ No newline at end of file diff --git a/backend/app/api_keys/models.py b/backend/app/api_keys/models.py new file mode 100644 index 0000000000..e4616f8f4f --- /dev/null +++ b/backend/app/api_keys/models.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlmodel import Field, SQLModel + +from app.utils import get_datetime_utc + + +class ApiKeyBase(SQLModel): + name: str | None = None + + +class ApiKey(ApiKeyBase, table=True): + __tablename__ = "api_keys" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="users.id", index=True) + # NOTE: consider encrypting this column in production + key: str + created_at: datetime | None = Field(default_factory=get_datetime_utc) diff --git a/backend/app/api_keys/router.py b/backend/app/api_keys/router.py new file mode 100644 index 0000000000..e5a9bb1f10 --- /dev/null +++ b/backend/app/api_keys/router.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import uuid +from typing import List + +from fastapi import APIRouter, HTTPException + +from app.api_keys import crud as api_keys_crud +from app.api_keys.schemas import ApiKeyCreate, ApiKeyPublic, ApiKeysList +from app.auth.dependencies import CurrentUser, SessionDep + +router = APIRouter(prefix="/api-keys", tags=["api-keys"]) + + +@router.post("/", response_model=ApiKeyPublic) +def create_api_key( + api_key_in: ApiKeyCreate, session: SessionDep, current_user: CurrentUser +): + """Upload an API key for the current user.""" + api_key = api_keys_crud.create_api_key(session=session, user_id=current_user.id, api_key_in=api_key_in) + return ApiKeyPublic(id=api_key.id, name=api_key.name, created_at=api_key.created_at) + + +@router.get("/", response_model=ApiKeysList) +def list_api_keys(session: SessionDep, current_user: CurrentUser): + """List API keys for the current user (does not include the secret key itself).""" + keys = api_keys_crud.get_api_keys_for_user(session=session, user_id=current_user.id) + public = [ApiKeyPublic(id=k.id, name=k.name, created_at=k.created_at) for k in keys] + return ApiKeysList(data=public, count=len(public)) + + +@router.delete("/{api_key_id}") +def delete_api_key(api_key_id: uuid.UUID, session: SessionDep, current_user: CurrentUser): + api_key = api_keys_crud.get_api_key(session=session, api_key_id=api_key_id) + if not api_key: + raise HTTPException(status_code=404, detail="API key not found") + if api_key.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to delete this API key") + api_keys_crud.delete_api_key(session=session, api_key_id=api_key_id) + return {"detail": "deleted"} diff --git a/backend/app/api_keys/schemas.py b/backend/app/api_keys/schemas.py new file mode 100644 index 0000000000..00af12f179 --- /dev/null +++ b/backend/app/api_keys/schemas.py @@ -0,0 +1,21 @@ +import uuid +from datetime import datetime + +from sqlmodel import SQLModel + + +class ApiKeyCreate(SQLModel): + name: str | None = None + key: str + + +class ApiKeyPublic(SQLModel): + id: uuid.UUID + name: str | None = None + created_at: datetime | None = None + # we intentionally do not return the key itself in public schema + + +class ApiKeysList(SQLModel): + data: list[ApiKeyPublic] + count: int diff --git a/backend/app/core/db.py b/backend/app/core/db.py index cb953dddf4..b18b467c69 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -11,6 +11,8 @@ def init_db(session: Session) -> None: from app.files.models import File # noqa: F401 from app.items.models import Item # noqa: F401 from app.users.models import User + # ensure api_keys model is imported so SQLModel registers the table + from app.api_keys.models import ApiKey # noqa: F401 from app.users.schemas import UserCreate from app.users.service import create_user diff --git a/backend/app/files/crud.py b/backend/app/files/crud.py new file mode 100644 index 0000000000..f26dd60beb --- /dev/null +++ b/backend/app/files/crud.py @@ -0,0 +1,36 @@ +import uuid + +from sqlmodel import Session + +from app.files.models import File +from app.files.schemas import FileCreate + + +def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: + db_file = File.model_validate(file_in, update={"user_id": user_id}) + session.add(db_file) + session.commit() + session.refresh(db_file) + return db_file + +def delete_file(*, session: Session, file_id: uuid.UUID) -> None: + db_file = session.get(File, file_id) + if db_file: + session.delete(db_file) + session.commit() + +def update_file_info( + session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None +) -> File | None: + db_file: File | None = session.get(File, file_id) + if db_file: + db_file.job_status = job_status + if job_id: + db_file.job_id = job_id + if err_msg: + db_file.err_msg = err_msg + session.add(db_file) + session.commit() + session.refresh(db_file) + return db_file + return None \ No newline at end of file diff --git a/backend/app/files/models.py b/backend/app/files/models.py index df3bc015d0..10cd0c6d63 100644 --- a/backend/app/files/models.py +++ b/backend/app/files/models.py @@ -24,7 +24,7 @@ class File(SQLModel, table=True): bank: str | None = Field(default=None, max_length=255) created_at: datetime = Field( default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore[call-arg] + sa_type=DateTime(timezone=True), # ty:ignore[invalid-argument-type] ) user_id: uuid.UUID = Field( foreign_key="users.id", nullable=False, ondelete="CASCADE" diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 6548a43462..9d7f8a77ac 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -9,15 +9,13 @@ from app.aws.client import upload_file_to_r2 from app.backend_pre_start import logger +from app.files.crud import create_file, delete_file, update_file_info from app.files.dependencies import CurrentUser, SessionDep from app.files.models import File from app.files.schemas import FileCreate, FilePublic, FilesPublic, FilesStatusRequest from app.files.service import ( - create_file, - delete_file, download_file, - get_gemini_response_for_file, - update_file_info, + get_gemini_response_for_file, download_file_with_account_code, ) from app.ocrs.service import get_ocr_job_status, post_ocr_jobs @@ -148,48 +146,16 @@ def download_new_version_excel(file_id: uuid.UUID, session: SessionDep, user: Cu raise HTTPException(status_code=403, detail="Not authorized to access this file") if file.job_status != "done": raise HTTPException(status_code=400, detail="OCR job is not done yet") - - # Get the existing excel bytes (as produced by download_table_excel_file) - excel_bytes, _ = download_file(file=file, user=user, type='xlsx') - - # Write input bytes to a temporary file and prepare an output temp file - input_fd, input_path = tempfile.mkstemp(suffix=".xlsx") - os.close(input_fd) - output_fd, output_path = tempfile.mkstemp(suffix=".xlsx") - os.close(output_fd) - try: - with open(input_path, "wb") as f: - f.write(excel_bytes) - - # Call the service function which talks to Gemini and writes the output xlsx - get_gemini_response_for_file(input_path=input_path, output_path=output_path) - - with open(output_path, "rb") as out_f: - new_bytes = out_f.read() - - safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename - excel_filename = f"{safe_name}_tables_new.xlsx" - encoded_filename = quote(excel_filename, safe="") - content_disposition = ( - f"attachment; filename=\"{excel_filename}\"; " - f"filename*=UTF-8''{encoded_filename}" - ) + ex_bytes, content_disposition = download_file_with_account_code(session=session, file=file, user=user) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) - return Response( - content=new_bytes, - media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - headers={"Content-Disposition": content_disposition}, - ) - finally: - try: - os.remove(input_path) - except Exception: - pass - try: - os.remove(output_path) - except Exception: - pass + return Response( + content=ex_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": content_disposition}, + ) @router.post("/batch/status", response_model=FilesPublic) def get_files_batch_status( diff --git a/backend/app/files/service.py b/backend/app/files/service.py index fa1b311c83..76e98066f2 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -1,100 +1,53 @@ -from app.core.config import settings import io -import uuid from io import StringIO -from urllib.parse import quote import pandas as pd from google import genai from pandas.core.frame import DataFrame from sqlmodel import Session +from app.api_keys.crud import get_api_key_by_user from app.aws.client import generate_presigned_put_url from app.aws.config import aws_settings -from app.backend_pre_start import logger +from app.core.config import settings from app.files.dependencies import CurrentUser from app.files.models import File -from app.files.schemas import FileCreate +from app.files.strategies import DOWNLOAD_STRATEGIES from app.files.utils import get_df_from_result_json +user_instruction = ( + "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung trong cột thứ 2 để xác định giao dịch " + "này thuộc mã tài khoản kế toán nào (mã này được lấy từ thị trường Việt Nam). Sau đó trả ra " + "cho tôi file mới có thêm cột mã tk, và tên tk ở cuối.\n\n" + "Dưới đây là nội dung file (nguyên văn). Hãy trả lại CHÍNH XÁC NỘI DUNG file mới dưới dạng CSV hoặc plain text, " + "không thêm giải thích, chú thích hay văn bản khác. Chỉ output nội dung file mới.\n\n" +) -def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: - db_file = File.model_validate(file_in, update={"user_id": user_id}) - session.add(db_file) - session.commit() - session.refresh(db_file) - return db_file - -def delete_file(*, session: Session, file_id: uuid.UUID) -> None: - db_file = session.get(File, file_id) - if db_file: - session.delete(db_file) - session.commit() - -def update_file_info( - session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None -) -> File | None: - db_file: File | None = session.get(File, file_id) - if db_file: - db_file.job_status = job_status - if job_id: - db_file.job_id = job_id - if err_msg: - db_file.err_msg = err_msg - session.add(db_file) - session.commit() - session.refresh(db_file) - return db_file - return None - -def download_file(file: File, user: CurrentUser, type: str = "excel") -> tuple[bytes, str]: +def download_file(file: File, user: CurrentUser, type: str = "xlsx") -> tuple[bytes, str]: """ Given a File record, download the file content from its URL and return bytes and a Content-Disposition header for the requested format. - Supported types: "excel", "csv", "json", "html". + The format-specific conversion is delegated to a :class:`DownloadStrategy` + looked up from ``DOWNLOAD_STRATEGIES``. + + Supported types: "xlsx", "csv", "json", "html". """ + strategy = DOWNLOAD_STRATEGIES.get(type) + if strategy is None: + raise ValueError(f"Unsupported file type requested: {type}") + json_key = f"{user.email}/{file.id}/result.json" - presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) + presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) df: DataFrame = get_df_from_result_json(presigned_url) safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename - - if type == "xlsx": - output = io.BytesIO() - with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] # ty:ignore[invalid-argument-type] - df.to_excel(writer, index=False, sheet_name="OCR Tables") - data_bytes = output.getvalue() - filename = f"{safe_name}_tables.xlsx" - elif type == "csv": - output = StringIO() - df.to_csv(output, index=False) - data_bytes = output.getvalue().encode("utf-8") - filename = f"{safe_name}_tables.csv" - elif type == "json": - text = df.to_json(orient="records", force_ascii=False) or "" - data_bytes = text.encode("utf-8") - filename = f"{safe_name}_tables.json" - elif type == "html": - text = df.to_html(index=False) or "" - data_bytes = text.encode("utf-8") - filename = f"{safe_name}_tables.html" - else: - raise ValueError(f"Unsupported file type requested: {type}") - - # RFC 5987: percent-encode the UTF-8 filename for the filename* parameter - encoded_filename = quote(filename, safe="") - logger.info(f"Generated filename: {filename}, encoded filename: {encoded_filename}") - content_disposition = ( - f"attachment; filename=\"{filename}\"; " - f"filename*=UTF-8''{encoded_filename}" - ) + data_bytes, content_disposition = strategy.convert(df, safe_name) return (data_bytes, content_disposition) - def get_gemini_response_for_file(input_path: str, output_path: str, *, model: str | None = None) -> None: """Read a local file (CSV or XLSX), send its contents to Gemini with the Vietnamese prompt, and write the model's returned contents into `output_path` as XLSX when @@ -113,6 +66,7 @@ def get_gemini_response_for_file(input_path: str, output_path: str, *, model: st Note: The GEMINI_API_KEY must be set in the environment for `genai.Client()` to authenticate. """ + client = genai.Client(api_key=settings.GMN_API_KEY) if model is None: @@ -129,13 +83,6 @@ def get_gemini_response_for_file(input_path: str, output_path: str, *, model: st file_text = f.read() # Build prompt that asks the model to return only the file contents (CSV/plain text) - user_instruction = ( - "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung trong cột thứ 2 để xác định giao dịch " - "này thuộc mã tài khoản kế toán nào (mã này được lấy từ thị trường Việt Nam). Sau đó trả ra " - "cho tôi file mới có thêm cột mã tk, và tên tk ở cuối.\n\n" - "Dưới đây là nội dung file (nguyên văn). Hãy trả lại CHÍNH XÁC NỘI DUNG file mới dưới dạng CSV hoặc plain text, " - "không thêm giải thích, chú thích hay văn bản khác. Chỉ output nội dung file mới.\n\n" - ) full_prompt = user_instruction + "---FILE-BEGIN---\n" + file_text + "\n---FILE-END---\n" @@ -157,3 +104,29 @@ def get_gemini_response_for_file(input_path: str, output_path: str, *, model: st # For non-xlsx output, write raw text with open(output_path, "w", encoding="utf-8") as out_f: out_f.write(resp_text) + +def download_file_with_account_code(session: Session, file: File, user: CurrentUser) -> tuple[bytes, str]: + """ + This is a placeholder for a future function that would download the file with an additional account code column. + The implementation would likely involve calling `get_gemini_response_for_file` to get the modified file content, + then returning the bytes and content disposition for that modified file. + """ + api_key = get_api_key_by_user(session=session, user_id=user.id) # type: ignore[call-arg] + client = genai.Client(api_key=api_key.key) + json_key = f"{user.email}/{file.id}/result.json" + model = "gemini-3-flash-preview" + + presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) + df: DataFrame = get_df_from_result_json(presigned_url) + file_text = df.to_csv(index=False) + + full_prompt = user_instruction + "---FILE-BEGIN---\n" + file_text + "\n---FILE-END---\n" + response = client.models.generate_content(model=model, contents=full_prompt) + resp_text = response.text or "" + + df_out = pd.read_csv(StringIO(resp_text)) + output = io.BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] # ty:ignore[invalid-argument-type] + df_out.to_excel(writer, index=False, sheet_name="OCR Tables with Account Codes") + + return output.getvalue(), DOWNLOAD_STRATEGIES["xlsx"].get_content_disposition(f"{file.filename.rsplit('.', 1)[0]}_with_account_codes.xlsx") \ No newline at end of file diff --git a/backend/app/files/strategies.py b/backend/app/files/strategies.py new file mode 100644 index 0000000000..0ed42feb5e --- /dev/null +++ b/backend/app/files/strategies.py @@ -0,0 +1,88 @@ +""" +Download format strategies for the Strategy pattern used in `download_file`. + +Each strategy encapsulates the logic for converting a pandas DataFrame into bytes +for a specific file format, along with the appropriate filename suffix. +""" + +import io +from abc import ABC, abstractmethod +from io import StringIO + +import pandas as pd +from pandas.core.frame import DataFrame + + +class DownloadStrategy(ABC): + """Abstract base class for file download format strategies.""" + + @abstractmethod + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + """ + Convert a DataFrame to bytes in the target format. + + Args: + df: The pandas DataFrame to convert. + safe_name: The base filename (without extension) to use. + + Returns: + A tuple of (file_bytes, filename). + """ + ... + + def encode_filename(self, filename: str) -> str: + """Helper method to percent-encode a UTF-8 filename for Content-Disposition.""" + from urllib.parse import quote + return quote(filename, safe="") + + def get_content_disposition(self, filename: str) -> str: + """Generate a Content-Disposition header value with both filename and filename*.""" + encoded_filename = self.encode_filename(filename) + return ( + f"attachment; filename=\"{filename}\"; " + f"filename*=UTF-8''{encoded_filename}" + ) + + +class XlsxDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as an Excel (.xlsx) file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + output = io.BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: # type: ignore[abstract] # ty:ignore[invalid-argument-type] + df.to_excel(writer, index=False, sheet_name="OCR Tables") + return output.getvalue(), self.get_content_disposition(f"{safe_name}_tables.xlsx") + + +class CsvDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as a CSV file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + output = StringIO() + df.to_csv(output, index=False) + return output.getvalue().encode("utf-8"), self.get_content_disposition(f"{safe_name}_tables.csv") + + +class JsonDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as a JSON file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + text = df.to_json(orient="records", force_ascii=False) or "" + return text.encode("utf-8"), self.get_content_disposition(f"{safe_name}_tables.json") + + +class HtmlDownloadStrategy(DownloadStrategy): + """Strategy for exporting a DataFrame as an HTML table file.""" + + def convert(self, df: DataFrame, safe_name: str) -> tuple[bytes, str]: + text = df.to_html(index=False) or "" + return text.encode("utf-8"), self.get_content_disposition(f"{safe_name}_tables.html") + + +# Registry mapping type strings to strategy instances +DOWNLOAD_STRATEGIES: dict[str, DownloadStrategy] = { + "xlsx": XlsxDownloadStrategy(), + "csv": CsvDownloadStrategy(), + "json": JsonDownloadStrategy(), + "html": HtmlDownloadStrategy(), +} diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py index fb054e1586..9617f39ca5 100644 --- a/backend/app/files/utils.py +++ b/backend/app/files/utils.py @@ -1,7 +1,9 @@ -from pandas import DataFrame from io import StringIO -import requests + import pandas as pd +import requests +from pandas import DataFrame + def extract_tables_from_ocr(data) -> pd.DataFrame: all_dfs: list[DataFrame] = [] @@ -21,8 +23,7 @@ def extract_tables_from_ocr(data) -> pd.DataFrame: df["__page__"] = page_idx + 1 # debug tracking all_dfs.append(df) except Exception as e: - print(f"Skip bad table on page {page_idx+1}: {e}") - + pass if not all_dfs: raise Exception("No tables found") diff --git a/backend/app/models.py b/backend/app/models.py index 0751ec5800..83e089792e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -9,6 +9,7 @@ """ from sqlmodel import SQLModel # noqa: F401 +from app.api_keys.models import ApiKey # noqa: F401 from app.auth.schemas import NewPassword, Token, TokenPayload # noqa: F401 from app.files.models import File # noqa: F401 from app.files.schemas import ( # noqa: F401 diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 37812f471a..595682fea0 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -5,8 +5,8 @@ from app.aws.client import upload_file_to_r2 from app.core.config import settings +from app.files.crud import update_file_info from app.files.models import File -from app.files.service import update_file_info from app.ocrs.constants import OcrJobStatus from app.ocrs.dependencies import CurrentUser, SessionDep from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse diff --git a/bun.lock b/bun.lock index 4a52f5d93d..20ea7983f2 100644 --- a/bun.lock +++ b/bun.lock @@ -10,11 +10,13 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -33,12 +35,15 @@ "clsx": "^2.1.1", "dayjs": "^1.11.20", "form-data": "4.0.5", + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.68.0", + "react-i18next": "^17.0.2", "react-icons": "^5.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", @@ -95,6 +100,8 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -203,17 +210,21 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], @@ -239,7 +250,9 @@ "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="], "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], @@ -587,6 +600,12 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + + "i18next": ["i18next@26.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-docker": ["is-docker@3.0.0", "", { "bin": "cli.js" }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -711,6 +730,8 @@ "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], + "react-i18next": ["react-i18next@17.0.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -787,6 +808,8 @@ "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "yaml"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], @@ -795,74 +818,30 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], - "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], - "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@tailwindcss/node/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "@tailwindcss/vite/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -907,30 +886,6 @@ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - "@tanstack/react-router/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], "@tanstack/router-devtools-core/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], diff --git a/frontend/package.json b/frontend/package.json index 6d7fe5d343..0468870b0f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,12 +39,15 @@ "clsx": "^2.1.1", "dayjs": "^1.11.20", "form-data": "4.0.5", + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.68.0", + "react-i18next": "^17.0.2", "react-icons": "^5.5.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index ac7025f0b4..e1ca77b329 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1,5 +1,83 @@ // This file is auto-generated by @hey-api/openapi-ts +export const ApiKeyCreateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name' + }, + key: { + type: 'string', + title: 'Key' + } + }, + type: 'object', + required: ['key'], + title: 'ApiKeyCreate' +} as const; + +export const ApiKeyPublicSchema = { + properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['id'], + title: 'ApiKeyPublic' +} as const; + +export const ApiKeysListSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ApiKeyPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ApiKeysList' +} as const; + export const Body_files_upload_file_endpointSchema = { properties: { file: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 4eab17d88c..b582842846 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,62 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; +import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; + +export class ApiKeysService { + /** + * List Api Keys + * List API keys for the current user (does not include the secret key itself). + * @returns ApiKeysList Successful Response + * @throws ApiError + */ + public static listApiKeys(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/api-keys/' + }); + } + + /** + * Create Api Key + * Upload an API key for the current user. + * @param data The data for the request. + * @param data.requestBody + * @returns ApiKeyPublic Successful Response + * @throws ApiError + */ + public static createApiKey(data: ApiKeysCreateApiKeyData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/api-keys/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Api Key + * @param data The data for the request. + * @param data.apiKeyId + * @returns unknown Successful Response + * @throws ApiError + */ + public static deleteApiKey(data: ApiKeysDeleteApiKeyData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/api-keys/{api_key_id}', + path: { + api_key_id: data.apiKeyId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} export class FilesService { /** diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 0094e56465..cd07096033 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1,5 +1,21 @@ // This file is auto-generated by @hey-api/openapi-ts +export type ApiKeyCreate = { + name?: (string | null); + key: string; +}; + +export type ApiKeyPublic = { + id: string; + name?: (string | null); + created_at?: (string | null); +}; + +export type ApiKeysList = { + data: Array; + count: number; +}; + export type Body_files_upload_file_endpoint = { file: (Blob | File); }; @@ -141,6 +157,20 @@ export type ValidationError = { }; }; +export type ApiKeysListApiKeysResponse = (ApiKeysList); + +export type ApiKeysCreateApiKeyData = { + requestBody: ApiKeyCreate; +}; + +export type ApiKeysCreateApiKeyResponse = (ApiKeyPublic); + +export type ApiKeysDeleteApiKeyData = { + apiKeyId: string; +}; + +export type ApiKeysDeleteApiKeyResponse = (unknown); + export type FilesUploadFileEndpointData = { formData: Body_files_upload_file_endpoint; }; diff --git a/frontend/src/components/Common/LanguageSwitcher.tsx b/frontend/src/components/Common/LanguageSwitcher.tsx new file mode 100644 index 0000000000..657ddf7284 --- /dev/null +++ b/frontend/src/components/Common/LanguageSwitcher.tsx @@ -0,0 +1,49 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { SUPPORTED_LANGUAGES } from "@/i18n" +import { useTranslation } from "react-i18next" + +export const LanguageSwitcher = () => { + const { i18n } = useTranslation() + + const current = + SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? + SUPPORTED_LANGUAGES[0] + + return ( + + + + + + {SUPPORTED_LANGUAGES.map((lang) => ( + i18n.changeLanguage(lang.code)} + className={ + i18n.language === lang.code ? "bg-accent font-medium" : "" + } + > + + {lang.label} + + ))} + + + ) +} + +export default LanguageSwitcher diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index eab504f514..7cc0755cb4 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -1,9 +1,7 @@ import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" import { DownloadIcon, Loader2, RefreshCcw } from "lucide-react" -import { useState } from "react" import { type FilePublic, FilesService } from "@/client" -import { OpenAPI } from "@/client/core/OpenAPI" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -14,79 +12,13 @@ import { import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" - -async function downloadFile(fileId: string, filename: string, type: string) { - const token = - typeof OpenAPI.TOKEN === "function" - ? await OpenAPI.TOKEN({} as never) - : OpenAPI.TOKEN - - const base = OpenAPI.BASE || "" - const response = await fetch(`${base}/api/v1/files/${fileId}/download?type=${type}`, { - method: "POST", - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - - if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`) - } - - const blob = await response.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - const safeName = filename.replace(/\.[^.]+$/, "") - a.href = url - a.download = `${safeName}_tables.${type}` - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) -} - -async function downloadNewVersion(fileId: string, filename: string) { - const token = - typeof OpenAPI.TOKEN === "function" - ? await OpenAPI.TOKEN({} as never) - : OpenAPI.TOKEN - - const base = OpenAPI.BASE || "" - const response = await fetch(`${base}/api/v1/files/${fileId}/download/new`, { - method: "POST", - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - - if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`) - } - - const blob = await response.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - const safeName = filename.replace(/\.[^.]+$/, "") - a.href = url - a.download = `${safeName}_tables_new.xlsx` - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) -} +import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" function DownloadMenu({ file }: { file: FilePublic }) { - const [loading, setLoading] = useState(false) + const { mutate: download, isPending } = useDownloadFile() - const handleSelect = async (format: string) => { - setLoading(true) - try { - await downloadFile(file.id, file.filename, format) - } catch (err) { - console.error(err) - } finally { - setLoading(false) - } + const handleSelect = (format: DownloadFormat) => { + download({ fileId: file.id, filename: file.filename, format }) } return ( @@ -97,9 +29,9 @@ function DownloadMenu({ file }: { file: FilePublic }) { size="sm" className="w-8 h-8 p-0" title="Download" - disabled={loading} + disabled={isPending} > - {loading ? ( + {isPending ? ( ) : ( @@ -107,60 +39,26 @@ function DownloadMenu({ file }: { file: FilePublic }) { - void handleSelect("xlsx")}> + handleSelect("xlsx")}> Excel (.xlsx) - void handleSelect("csv")}> + handleSelect("xlsx-acc-code")}> + Analyze Account Code then Excel (.xlsx) + + handleSelect("csv")}> CSV (.csv) - - void handleSelect("json")}> + handleSelect("json")}> JSON (.json) - - void handleSelect("html")}> + handleSelect("html")}> HTML (.html) - void handleSelect("docx")}> - DOCX (.docx) — not supported - ) } -function DownloadNewButton({ file }: { file: FilePublic }) { - const [loading, setLoading] = useState(false) - - const handleClick = async () => { - setLoading(true) - try { - await downloadNewVersion(file.id, file.filename) - } catch (err) { - console.error(err) - } finally { - setLoading(false) - } - } - - return ( - - ) -} - export const columns: ColumnDef[] = [ { accessorKey: "filename", @@ -235,10 +133,7 @@ export const columns: ColumnDef[] = [ )} {file.job_status === "done" && ( - <> - - - + )}
    ) diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 91f18c2fac..ae1c1f7d81 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Files, Home, Users } from "lucide-react" +import { Files, Home, Users, Key } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -15,6 +15,7 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/dashboard" }, { icon: Files, title: "Files", path: "/files" }, + { icon: Key, title: "API Keys", path: "/api-keys" }, ] export function AppSidebar() { diff --git a/frontend/src/components/faq.tsx b/frontend/src/components/faq.tsx index daa04859cd..9bca01e0ee 100644 --- a/frontend/src/components/faq.tsx +++ b/frontend/src/components/faq.tsx @@ -1,57 +1,55 @@ "use client" import * as Accordion from "@radix-ui/react-accordion" - -const faqs = [ - { - question: "What is KeToanAuto?", - answer: - "KeToanAuto is an all-in-one online PDF/Images converter that lets you transform PDF/Images files into various formats, including Excel (XLSX). No installation or technical skills required.", - }, - { - question: "Is KeToanAuto safe to use?", - answer: - "Yes, absolutely. We use advanced encryption to ensure your sensitive data stays secure throughout the entire process. Your privacy is our priority.", - }, - { - question: "Is KeToanAuto available as a subscription or one-time purchase?", - answer: - "Your first file is always free to convert. After that, we offer flexible subscription plans to meet your needs.", - }, - // { - // question: "How to edit PDF files using KeToanAuto?", - // answer: - // "KeToanAuto provides intuitive editing tools. Simply upload your PDF, use our editor to make changes, and download your modified file.", - // }, - { - question: "What file types does KeToanAuto support?", - answer: - "KeToanAuto supports conversion to and from PDF, Excel, Word, PowerPoint, and many other popular document formats.", - }, - { - question: "What should I do if I encounter problems?", - answer: - "Our friendly support team is here to help. Contact us directly through the support form, and we'll get back to you quickly with a solution.", - }, -] +import { useTranslation } from "react-i18next" export default function FAQ() { + const { t } = useTranslation() + + const faqs = [ + { + id: "whatIs", + question: t("faq.whatIs.question"), + answer: t("faq.whatIs.answer"), + }, + { + id: "isSafe", + question: t("faq.isSafe.question"), + answer: t("faq.isSafe.answer"), + }, + { + id: "pricing", + question: t("faq.pricing.question"), + answer: t("faq.pricing.answer"), + }, + { + id: "fileTypes", + question: t("faq.fileTypes.question"), + answer: t("faq.fileTypes.answer"), + }, + { + id: "problems", + question: t("faq.problems.question"), + answer: t("faq.problems.answer"), + }, + ] + return (

    - Frequently Asked Questions + {t("faq.heading")}

    - Find answers to common questions + {t("faq.subheading")}

    {faqs.map((faq, index) => ( diff --git a/frontend/src/components/features.tsx b/frontend/src/components/features.tsx index bb547bac43..8e6744c955 100644 --- a/frontend/src/components/features.tsx +++ b/frontend/src/components/features.tsx @@ -1,52 +1,50 @@ -const features = [ - { - icon: "📄", - title: "Multiple File Types", - description: - "Convert bank statements from PDF and image formats. Supports JPG, PNG, and PDF uploads.", - }, - { - icon: "🔒", - title: "Bank-Grade Security", - description: - "Your financial data is encrypted and never stored on our servers. Complete privacy guaranteed.", - }, - { - icon: "⚡", - title: "Instant Conversion", - description: - "Process your bank statements in seconds. Get organized Excel files ready to use immediately.", - }, - { - icon: "✅", - title: "Accurate Data Extraction", - description: - "Preserves all transaction details, amounts, and dates with 99.9% accuracy. No data loss.", - }, - { - icon: "📊", - title: "Ready for Analysis", - description: - "Converted Excel files are fully formatted and compatible with all spreadsheet applications.", - }, - { - icon: "🚀", - title: "Zero Setup Required", - description: - "No installation, no registration required. Start converting your bank statements instantly.", - }, -] +import { useTranslation } from "react-i18next" export default function Features() { + const { t } = useTranslation() + + const features = [ + { + icon: "📄", + title: t("features.multipleFileTypes.title"), + description: t("features.multipleFileTypes.description"), + }, + { + icon: "🔒", + title: t("features.security.title"), + description: t("features.security.description"), + }, + { + icon: "⚡", + title: t("features.instantConversion.title"), + description: t("features.instantConversion.description"), + }, + { + icon: "✅", + title: t("features.accurateExtraction.title"), + description: t("features.accurateExtraction.description"), + }, + { + icon: "📊", + title: t("features.readyForAnalysis.title"), + description: t("features.readyForAnalysis.description"), + }, + { + icon: "🚀", + title: t("features.zeroSetup.title"), + description: t("features.zeroSetup.description"), + }, + ] + return (

    - Why Choose KeToanAuto + {t("features.heading")}

    - The smartest way to organize your bank statements + {t("features.subheading")}

    diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index 26f037d0f6..0ba6eb1196 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -1,4 +1,7 @@ +import { useTranslation } from "react-i18next" + export default function Footer() { + const { t } = useTranslation() return (
    @@ -26,19 +29,19 @@ export default function Footer() { PDF Guru

    - Convert your PDFs with confidence + {t("footer.tagline")}

    -

    Product

    +

    {t("footer.product")}

    • - Features + {t("footer.features")}
    • @@ -46,7 +49,7 @@ export default function Footer() { href="/pricing" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Pricing + {t("footer.pricing")}
    • @@ -54,7 +57,7 @@ export default function Footer() { href="/security" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Security + {t("footer.security")}
    • @@ -62,21 +65,21 @@ export default function Footer() { href="/blog" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Blog + {t("footer.blog")}
    -

    Company

    +

    {t("footer.company")}

    • - About + {t("footer.about")}
    • @@ -84,7 +87,7 @@ export default function Footer() { href="/contact" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Contact + {t("footer.contact")}
    • @@ -92,7 +95,7 @@ export default function Footer() { href="/support" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Support + {t("footer.support")}
    • @@ -100,21 +103,21 @@ export default function Footer() { href="/careers" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Careers + {t("footer.careers")}
    -

    Legal

    +

    {t("footer.legal")}

    • - Privacy Policy + {t("footer.privacyPolicy")}
    • @@ -122,7 +125,7 @@ export default function Footer() { href="/terms" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Terms of Use + {t("footer.termsOfUse")}
    • @@ -130,7 +133,7 @@ export default function Footer() { href="/cookies" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Cookie Policy + {t("footer.cookiePolicy")}
    @@ -140,7 +143,7 @@ export default function Footer() {

    - © {new Date().getFullYear()} PDF Guru. All rights reserved. + © {new Date().getFullYear()} PDF Guru. {t("footer.rights")}

    ("signin") + const { t } = useTranslation() return (
    @@ -24,30 +27,31 @@ export default function Header() { href="/#features" className="text-sm text-muted-foreground hover:text-foreground transition-colors" > - Features + {t("nav.features")} - Pricing + {t("nav.pricing")} - How it works + {t("nav.howItWorks")} - FAQ + {t("nav.faq")} {/* Right — Actions */}
    + {!isLoggedIn() ? ( <> @@ -60,7 +64,7 @@ export default function Header() { }} className="hidden sm:inline-flex text-sm font-medium text-muted-foreground hover:text-foreground transition-colors" > - Sign In + {t("nav.signIn")}
    - Dashboard + {t("nav.dashboard")}
    diff --git a/frontend/src/components/hero.tsx b/frontend/src/components/hero.tsx index 2f24628e5b..e4bb3b32ba 100644 --- a/frontend/src/components/hero.tsx +++ b/frontend/src/components/hero.tsx @@ -1,11 +1,13 @@ import { useNavigate } from "@tanstack/react-router" import { useState } from "react" +import { useTranslation } from "react-i18next" import FileUploadZone from "@/components/FileUploadZone" import { isLoggedIn } from "@/hooks/useAuth" export default function Hero() { const [isDrag] = useState(false) const navigate = useNavigate() + const { t } = useTranslation() const requireAuth = (action: () => void) => { if (!isLoggedIn()) { @@ -33,11 +35,10 @@ export default function Hero() {

    - Convert Bank Statements to Excel + {t("hero.title")}

    - Upload your PDF or image bank statements and get organized Excel - files instantly + {t("hero.subtitle")}

    @@ -49,9 +50,9 @@ export default function Hero() { ? "border-primary bg-primary/10 shadow-2xl scale-105" : "border-primary/40 bg-primary/5 hover:bg-primary/8 hover:border-primary/60 shadow-lg hover:shadow-2xl" }`} - title={"Drag and drop your bank statement"} - description={"or click to browse"} - sizeHint={"Supports PDF and images (JPG, PNG) up to 100 MB"} + title={t("hero.dragDrop")} + description={t("hero.browse")} + sizeHint={t("hero.sizeHint")} />
    @@ -64,7 +65,7 @@ export default function Hero() { stroke="currentColor" viewBox="0 0 24 24" > - Instant Conversion + {t("hero.instantConversion")}

    - Instant Conversion + {t("hero.instantConversion")}

    - PDFs & images to Excel + {t("hero.pdfsToExcel")}

    @@ -88,7 +89,7 @@ export default function Hero() { stroke="currentColor" viewBox="0 0 24 24" > - Bank-Grade Security + {t("hero.bankGradeSecurity")}

    - Bank-Grade Security + {t("hero.bankGradeSecurity")}

    - Your data is encrypted + {t("hero.dataEncrypted")}

    @@ -112,7 +113,7 @@ export default function Hero() { stroke="currentColor" viewBox="0 0 24 24" > - 100% Accurate + {t("hero.accurate")}

    - 100% Accurate + {t("hero.accurate")}

    - Preserves all data + {t("hero.preservesData")}

    diff --git a/frontend/src/components/how-it-works.tsx b/frontend/src/components/how-it-works.tsx index a4a97186a4..6a76aecfdb 100644 --- a/frontend/src/components/how-it-works.tsx +++ b/frontend/src/components/how-it-works.tsx @@ -1,40 +1,41 @@ -const steps = [ - { - number: "1", - title: "Upload Your Bank Statement", - description: - "Drag and drop your PDF or image bank statement. Supports JPG, PNG, and PDF formats.", - }, - { - number: "2", - title: "We Extract the Data", - description: - "Our AI-powered system analyzes and extracts all transaction details automatically in seconds.", - }, - { - number: "3", - title: "Download as Excel", - description: - "Your bank statement is now organized in an Excel file, ready for analysis and archiving.", - }, -] +import { useTranslation } from "react-i18next" export default function HowItWorks() { + const { t } = useTranslation() + + const steps = [ + { + number: "1", + title: t("howItWorks.step1.title"), + description: t("howItWorks.step1.description"), + }, + { + number: "2", + title: t("howItWorks.step2.title"), + description: t("howItWorks.step2.description"), + }, + { + number: "3", + title: t("howItWorks.step3.title"), + description: t("howItWorks.step3.description"), + }, + ] + return (

    - How KeToanAuto Works + {t("howItWorks.heading")}

    - Three simple steps to organize your bank statements + {t("howItWorks.subheading")}

    {steps.map((step, index) => ( -
    +
    {index < steps.length - 1 && (
    )} @@ -55,8 +56,7 @@ export default function HowItWorks() {

    - Convert your first bank statement for free. Unlock unlimited - conversions with KeToanAuto Pro. + {t("howItWorks.cta")}

    diff --git a/frontend/src/components/testimonials.tsx b/frontend/src/components/testimonials.tsx index 10d7f1d526..71e153fd60 100644 --- a/frontend/src/components/testimonials.tsx +++ b/frontend/src/components/testimonials.tsx @@ -1,44 +1,48 @@ -const testimonials = [ - { - name: "Sarah Martinez", - role: "Business Manager", - content: - "Easy to use, good service. Exactly what I needed for converting my documents.", - rating: 5, - }, - { - name: "James Chen", - role: "Freelance Designer", - content: - "Great experience, high quality service! The conversion was perfect and very fast.", - rating: 5, - }, - { - name: "Emily Rodriguez", - role: "Data Analyst", - content: - "Good Service, Very good customer service! They helped me when I had questions.", - rating: 5, - }, -] +import { useTranslation } from "react-i18next" export default function Testimonials() { + const { t } = useTranslation() + + const testimonials = [ + { + id: "sarah", + name: t("testimonials.sarah.name"), + role: t("testimonials.sarah.role"), + content: t("testimonials.sarah.content"), + rating: 5, + }, + { + id: "james", + name: t("testimonials.james.name"), + role: t("testimonials.james.role"), + content: t("testimonials.james.content"), + rating: 5, + }, + { + id: "emily", + name: t("testimonials.emily.name"), + role: t("testimonials.emily.role"), + content: t("testimonials.emily.content"), + rating: 5, + }, + ] + return (

    - What Users Are Saying + {t("testimonials.heading")}

    - Trusted by thousands of users worldwide + {t("testimonials.subheading")}

    - {testimonials.map((testimonial, index) => ( + {testimonials.map((testimonial) => (
    @@ -60,7 +64,7 @@ export default function Testimonials() {

    - ✓ Verified + ✓ {t("testimonials.verified")}
    ))} diff --git a/frontend/src/hooks/useDownloadFile.ts b/frontend/src/hooks/useDownloadFile.ts new file mode 100644 index 0000000000..80269f01b4 --- /dev/null +++ b/frontend/src/hooks/useDownloadFile.ts @@ -0,0 +1,70 @@ +import { useMutation } from "@tanstack/react-query" +import { OpenAPI } from "@/client/core/OpenAPI" +import useCustomToast from "./useCustomToast" +import { ApiError } from "@/client" + +async function fetchDownload(url: string): Promise { + const token = + typeof OpenAPI.TOKEN === "function" + ? await OpenAPI.TOKEN({} as never) + : OpenAPI.TOKEN + + const response = await fetch(url, { + method: "POST", + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) + console.log('response', response) + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`) + } + + return response.blob() +} + +function triggerBlobDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +export type DownloadFormat = "xlsx" | "xlsx-acc-code" | "csv" | "json" | "html" + +export interface DownloadFileParams { + fileId: string + filename: string + format: DownloadFormat +} + +export function useDownloadFile() { + const { showErrorToast } = useCustomToast() + + return useMutation({ + mutationFn: async ({ fileId, filename, format }: DownloadFileParams) => { + const base = OpenAPI.BASE || "" + const safeName = filename.replace(/\.[^.]+$/, "") + + if (format === "xlsx-acc-code") { + const blob = await fetchDownload( + `${base}/api/v1/files/${fileId}/download/new`, + ) + triggerBlobDownload(blob, `${safeName}_tables_with_acc_codes.xlsx`) + } else { + const blob = await fetchDownload( + `${base}/api/v1/files/${fileId}/download?type=${format}`, + ) + triggerBlobDownload(blob, `${safeName}_tables.${format}`) + } + }, + onError: (err: ApiError) => { + console.log("Download error:", err) + showErrorToast(err instanceof Error ? err.message : "Download failed") + }, + }) +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000000..0b759ad1e7 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,44 @@ +import i18n from "i18next" +import LanguageDetector from "i18next-browser-languagedetector" +import { initReactI18next } from "react-i18next" + +import enCommon from "./locales/en/common.json" +import viCommon from "./locales/vi/common.json" + +/** + * All supported languages. To add a new language: + * 1. Create src/i18n/locales//common.json + * 2. Import it above and add it to `resources` below + * 3. Add an entry to SUPPORTED_LANGUAGES + */ +export const SUPPORTED_LANGUAGES = [ + { code: "en", label: "English", flag: "🇺🇸" }, + { code: "vi", label: "Tiếng Việt", flag: "🇻🇳" }, +] as const + +export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"] + +const resources = { + en: { common: enCommon }, + vi: { common: viCommon }, +} + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + defaultNS: "common", + fallbackLng: "en", + supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code), + interpolation: { + escapeValue: false, // React already escapes values + }, + detection: { + order: ["localStorage", "navigator"], + caches: ["localStorage"], + lookupLocalStorage: "app-language", + }, + }) + +export default i18n diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json new file mode 100644 index 0000000000..8f307aebe2 --- /dev/null +++ b/frontend/src/i18n/locales/en/common.json @@ -0,0 +1,141 @@ +{ + "nav": { + "features": "Features", + "pricing": "Pricing", + "howItWorks": "How it works", + "faq": "FAQ", + "signIn": "Sign In", + "signUp": "Sign Up", + "dashboard": "Dashboard" + }, + "hero": { + "title": "Convert Bank Statements to Excel", + "subtitle": "Upload your PDF or image bank statements and get organized Excel files instantly", + "dragDrop": "Drag and drop your bank statement", + "browse": "or click to browse", + "sizeHint": "Supports PDF and images (JPG, PNG) up to 100 MB", + "instantConversion": "Instant Conversion", + "pdfsToExcel": "PDFs & images to Excel", + "bankGradeSecurity": "Bank-Grade Security", + "dataEncrypted": "Your data is encrypted", + "accurate": "100% Accurate", + "preservesData": "Preserves all data" + }, + "features": { + "heading": "Why Choose KeToanAuto", + "subheading": "The smartest way to organize your bank statements", + "multipleFileTypes": { + "title": "Multiple File Types", + "description": "Convert bank statements from PDF and image formats. Supports JPG, PNG, and PDF uploads." + }, + "security": { + "title": "Bank-Grade Security", + "description": "Your financial data is encrypted and never stored on our servers. Complete privacy guaranteed." + }, + "instantConversion": { + "title": "Instant Conversion", + "description": "Process your bank statements in seconds. Get organized Excel files ready to use immediately." + }, + "accurateExtraction": { + "title": "Accurate Data Extraction", + "description": "Preserves all transaction details, amounts, and dates with 99.9% accuracy. No data loss." + }, + "readyForAnalysis": { + "title": "Ready for Analysis", + "description": "Converted Excel files are fully formatted and compatible with all spreadsheet applications." + }, + "zeroSetup": { + "title": "Zero Setup Required", + "description": "No installation, no registration required. Start converting your bank statements instantly." + } + }, + "howItWorks": { + "heading": "How KeToanAuto Works", + "subheading": "Three simple steps to organize your bank statements", + "step1": { + "title": "Upload Your Bank Statement", + "description": "Drag and drop your PDF or image bank statement. Supports JPG, PNG, and PDF formats." + }, + "step2": { + "title": "We Extract the Data", + "description": "Our AI-powered system analyzes and extracts all transaction details automatically in seconds." + }, + "step3": { + "title": "Download as Excel", + "description": "Your bank statement is now organized in an Excel file, ready for analysis and archiving." + }, + "cta": "Convert your first bank statement for free. Unlock unlimited conversions with KeToanAuto Pro." + }, + "testimonials": { + "heading": "What Users Are Saying", + "subheading": "Trusted by thousands of users worldwide", + "verified": "Verified", + "sarah": { + "name": "Sarah Martinez", + "role": "Business Manager", + "content": "Easy to use, good service. Exactly what I needed for converting my documents." + }, + "james": { + "name": "James Chen", + "role": "Freelance Designer", + "content": "Great experience, high quality service! The conversion was perfect and very fast." + }, + "emily": { + "name": "Emily Rodriguez", + "role": "Data Analyst", + "content": "Good Service, Very good customer service! They helped me when I had questions." + } + }, + "faq": { + "heading": "Frequently Asked Questions", + "subheading": "Find answers to common questions", + "whatIs": { + "question": "What is KeToanAuto?", + "answer": "KeToanAuto is an all-in-one online PDF/Images converter that lets you transform PDF/Images files into various formats, including Excel (XLSX). No installation or technical skills required." + }, + "isSafe": { + "question": "Is KeToanAuto safe to use?", + "answer": "Yes, absolutely. We use advanced encryption to ensure your sensitive data stays secure throughout the entire process. Your privacy is our priority." + }, + "pricing": { + "question": "Is KeToanAuto available as a subscription or one-time purchase?", + "answer": "Your first file is always free to convert. After that, we offer flexible subscription plans to meet your needs." + }, + "fileTypes": { + "question": "What file types does KeToanAuto support?", + "answer": "KeToanAuto supports conversion to and from PDF, Excel, Word, PowerPoint, and many other popular document formats." + }, + "problems": { + "question": "What should I do if I encounter problems?", + "answer": "Our friendly support team is here to help. Contact us directly through the support form, and we'll get back to you quickly with a solution." + } + }, + "footer": { + "tagline": "Convert your PDFs with confidence", + "product": "Product", + "company": "Company", + "legal": "Legal", + "features": "Features", + "pricing": "Pricing", + "security": "Security", + "blog": "Blog", + "about": "About", + "contact": "Contact", + "support": "Support", + "careers": "Careers", + "privacyPolicy": "Privacy Policy", + "termsOfUse": "Terms of Use", + "cookiePolicy": "Cookie Policy", + "rights": "All rights reserved." + }, + "common": { + "loading": "Loading...", + "error": "Something went wrong", + "retry": "Retry", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "close": "Close" + } +} diff --git a/frontend/src/i18n/locales/vi/common.json b/frontend/src/i18n/locales/vi/common.json new file mode 100644 index 0000000000..77c167c623 --- /dev/null +++ b/frontend/src/i18n/locales/vi/common.json @@ -0,0 +1,141 @@ +{ + "nav": { + "features": "Tính năng", + "pricing": "Bảng giá", + "howItWorks": "Cách hoạt động", + "faq": "Câu hỏi thường gặp", + "signIn": "Đăng nhập", + "signUp": "Đăng ký", + "dashboard": "Bảng điều khiển" + }, + "hero": { + "title": "Chuyển đổi sao kê ngân hàng sang Excel", + "subtitle": "Tải lên file PDF hoặc hình ảnh sao kê ngân hàng và nhận file Excel có tổ chức ngay lập tức", + "dragDrop": "Kéo và thả sao kê ngân hàng của bạn", + "browse": "hoặc nhấp để chọn file", + "sizeHint": "Hỗ trợ PDF và hình ảnh (JPG, PNG) lên đến 100 MB", + "instantConversion": "Chuyển đổi tức thì", + "pdfsToExcel": "PDF & hình ảnh sang Excel", + "bankGradeSecurity": "Bảo mật cấp ngân hàng", + "dataEncrypted": "Dữ liệu của bạn được mã hóa", + "accurate": "100% Chính xác", + "preservesData": "Bảo toàn toàn bộ dữ liệu" + }, + "features": { + "heading": "Tại sao chọn KeToanAuto", + "subheading": "Cách thông minh nhất để tổ chức sao kê ngân hàng của bạn", + "multipleFileTypes": { + "title": "Nhiều loại file", + "description": "Chuyển đổi sao kê ngân hàng từ định dạng PDF và hình ảnh. Hỗ trợ tải lên JPG, PNG và PDF." + }, + "security": { + "title": "Bảo mật cấp ngân hàng", + "description": "Dữ liệu tài chính của bạn được mã hóa và không bao giờ lưu trữ trên máy chủ của chúng tôi. Đảm bảo quyền riêng tư hoàn toàn." + }, + "instantConversion": { + "title": "Chuyển đổi tức thì", + "description": "Xử lý sao kê ngân hàng của bạn trong vài giây. Nhận file Excel có tổ chức sẵn sàng sử dụng ngay." + }, + "accurateExtraction": { + "title": "Trích xuất dữ liệu chính xác", + "description": "Bảo toàn tất cả chi tiết giao dịch, số tiền và ngày tháng với độ chính xác 99,9%. Không mất dữ liệu." + }, + "readyForAnalysis": { + "title": "Sẵn sàng để phân tích", + "description": "File Excel được chuyển đổi có định dạng đầy đủ và tương thích với tất cả các ứng dụng bảng tính." + }, + "zeroSetup": { + "title": "Không cần cài đặt", + "description": "Không cần cài đặt, không cần đăng ký. Bắt đầu chuyển đổi sao kê ngân hàng của bạn ngay lập tức." + } + }, + "howItWorks": { + "heading": "KeToanAuto hoạt động như thế nào", + "subheading": "Ba bước đơn giản để tổ chức sao kê ngân hàng của bạn", + "step1": { + "title": "Tải lên sao kê ngân hàng", + "description": "Kéo và thả file PDF hoặc hình ảnh sao kê ngân hàng. Hỗ trợ định dạng JPG, PNG và PDF." + }, + "step2": { + "title": "Chúng tôi trích xuất dữ liệu", + "description": "Hệ thống AI của chúng tôi tự động phân tích và trích xuất tất cả chi tiết giao dịch trong vài giây." + }, + "step3": { + "title": "Tải xuống dưới dạng Excel", + "description": "Sao kê ngân hàng của bạn giờ đây được tổ chức trong file Excel, sẵn sàng để phân tích và lưu trữ." + }, + "cta": "Chuyển đổi sao kê ngân hàng đầu tiên của bạn miễn phí. Mở khóa chuyển đổi không giới hạn với KeToanAuto Pro." + }, + "testimonials": { + "heading": "Người dùng nói gì về chúng tôi", + "subheading": "Được tin dùng bởi hàng nghìn người dùng trên toàn thế giới", + "verified": "Đã xác minh", + "sarah": { + "name": "Sarah Martinez", + "role": "Quản lý kinh doanh", + "content": "Dễ sử dụng, dịch vụ tốt. Đúng những gì tôi cần để chuyển đổi tài liệu." + }, + "james": { + "name": "James Chen", + "role": "Nhà thiết kế tự do", + "content": "Trải nghiệm tuyệt vời, dịch vụ chất lượng cao! Việc chuyển đổi hoàn hảo và rất nhanh." + }, + "emily": { + "name": "Emily Rodriguez", + "role": "Nhà phân tích dữ liệu", + "content": "Dịch vụ tốt, dịch vụ khách hàng rất tốt! Họ đã giúp tôi khi tôi có thắc mắc." + } + }, + "faq": { + "heading": "Câu hỏi thường gặp", + "subheading": "Tìm câu trả lời cho các câu hỏi phổ biến", + "whatIs": { + "question": "KeToanAuto là gì?", + "answer": "KeToanAuto là một công cụ chuyển đổi PDF/Hình ảnh trực tuyến tất cả trong một, cho phép bạn chuyển đổi file PDF/Hình ảnh sang nhiều định dạng khác nhau, bao gồm Excel (XLSX). Không cần cài đặt hoặc kỹ năng kỹ thuật." + }, + "isSafe": { + "question": "KeToanAuto có an toàn để sử dụng không?", + "answer": "Có, hoàn toàn. Chúng tôi sử dụng mã hóa nâng cao để đảm bảo dữ liệu nhạy cảm của bạn được bảo mật trong suốt quá trình. Quyền riêng tư của bạn là ưu tiên của chúng tôi." + }, + "pricing": { + "question": "KeToanAuto có đăng ký theo tháng hay mua một lần?", + "answer": "File đầu tiên của bạn luôn được chuyển đổi miễn phí. Sau đó, chúng tôi cung cấp các gói đăng ký linh hoạt để đáp ứng nhu cầu của bạn." + }, + "fileTypes": { + "question": "KeToanAuto hỗ trợ những loại file nào?", + "answer": "KeToanAuto hỗ trợ chuyển đổi sang và từ PDF, Excel, Word, PowerPoint và nhiều định dạng tài liệu phổ biến khác." + }, + "problems": { + "question": "Tôi phải làm gì nếu gặp sự cố?", + "answer": "Đội ngũ hỗ trợ thân thiện của chúng tôi luôn sẵn sàng giúp đỡ. Liên hệ trực tiếp với chúng tôi qua biểu mẫu hỗ trợ, và chúng tôi sẽ phản hồi bạn nhanh chóng với giải pháp." + } + }, + "footer": { + "tagline": "Chuyển đổi PDF của bạn với sự tự tin", + "product": "Sản phẩm", + "company": "Công ty", + "legal": "Pháp lý", + "features": "Tính năng", + "pricing": "Bảng giá", + "security": "Bảo mật", + "blog": "Blog", + "about": "Giới thiệu", + "contact": "Liên hệ", + "support": "Hỗ trợ", + "careers": "Tuyển dụng", + "privacyPolicy": "Chính sách bảo mật", + "termsOfUse": "Điều khoản sử dụng", + "cookiePolicy": "Chính sách cookie", + "rights": "Bản quyền thuộc về chúng tôi." + }, + "common": { + "loading": "Đang tải...", + "error": "Đã xảy ra lỗi", + "retry": "Thử lại", + "cancel": "Hủy", + "save": "Lưu", + "delete": "Xóa", + "edit": "Chỉnh sửa", + "close": "Đóng" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6c89a2adba..3f6c750b45 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -11,6 +11,7 @@ import { ApiError, OpenAPI } from "./client" import { LoadingSpinnerProvider } from "./components/loading-spinner-provider" import { ThemeProvider } from "./components/theme-provider" import { Toaster } from "./components/ui/sonner" +import "./i18n" import "./index.css" import { routeTree } from "./routeTree.gen" diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 5201c4cb2c..5e8d53bb3b 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' import { Route as LayoutFilesRouteImport } from './routes/_layout/files' import { Route as LayoutDashboardRouteImport } from './routes/_layout/dashboard' +import { Route as LayoutApiKeysRouteImport } from './routes/_layout/api-keys' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' const SignupRoute = SignupRouteImport.update({ @@ -81,6 +82,11 @@ const LayoutDashboardRoute = LayoutDashboardRouteImport.update({ path: '/dashboard', getParentRoute: () => LayoutRoute, } as any) +const LayoutApiKeysRoute = LayoutApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', + getParentRoute: () => LayoutRoute, +} as any) const LayoutAdminRoute = LayoutAdminRouteImport.update({ id: '/admin', path: '/admin', @@ -94,6 +100,7 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/api-keys': typeof LayoutApiKeysRoute '/dashboard': typeof LayoutDashboardRoute '/files': typeof LayoutFilesRoute '/items': typeof LayoutItemsRoute @@ -107,6 +114,7 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute + '/api-keys': typeof LayoutApiKeysRoute '/dashboard': typeof LayoutDashboardRoute '/files': typeof LayoutFilesRoute '/items': typeof LayoutItemsRoute @@ -122,6 +130,7 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute + '/_layout/api-keys': typeof LayoutApiKeysRoute '/_layout/dashboard': typeof LayoutDashboardRoute '/_layout/files': typeof LayoutFilesRoute '/_layout/items': typeof LayoutItemsRoute @@ -138,6 +147,7 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/api-keys' | '/dashboard' | '/files' | '/items' @@ -151,6 +161,7 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' + | '/api-keys' | '/dashboard' | '/files' | '/items' @@ -165,6 +176,7 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/_layout/admin' + | '/_layout/api-keys' | '/_layout/dashboard' | '/_layout/files' | '/_layout/items' @@ -268,6 +280,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutDashboardRouteImport parentRoute: typeof LayoutRoute } + '/_layout/api-keys': { + id: '/_layout/api-keys' + path: '/api-keys' + fullPath: '/api-keys' + preLoaderRoute: typeof LayoutApiKeysRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/admin': { id: '/_layout/admin' path: '/admin' @@ -280,6 +299,7 @@ declare module '@tanstack/react-router' { interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute + LayoutApiKeysRoute: typeof LayoutApiKeysRoute LayoutDashboardRoute: typeof LayoutDashboardRoute LayoutFilesRoute: typeof LayoutFilesRoute LayoutItemsRoute: typeof LayoutItemsRoute @@ -288,6 +308,7 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, + LayoutApiKeysRoute: LayoutApiKeysRoute, LayoutDashboardRoute: LayoutDashboardRoute, LayoutFilesRoute: LayoutFilesRoute, LayoutItemsRoute: LayoutItemsRoute, diff --git a/frontend/src/routes/_layout/api-keys.tsx b/frontend/src/routes/_layout/api-keys.tsx new file mode 100644 index 0000000000..13e938577b --- /dev/null +++ b/frontend/src/routes/_layout/api-keys.tsx @@ -0,0 +1,225 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { createFileRoute } from "@tanstack/react-router" +import { Plus, Trash } from "lucide-react" +import { useState } from "react" + +const SUPPORTED_PROVIDERS = [ + { label: "Google Generative AI", name: "google-genai" }, +] as const + +import { ApiKeysService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import useAuth from "@/hooks/useAuth" +import useCustomToast from "@/hooks/useCustomToast" + +export const Route = createFileRoute("/_layout/api-keys")({ + component: ApiKeysPage, + head: () => ({ + meta: [ + { + title: "API Keys - FastAPI Template", + }, + ], + }), +}) + +function AddApiKeyDialog({ + onAdd, + isPending, +}: { + onAdd: (payload: { name?: string; key: string }) => void + isPending: boolean +}) { + const [open, setOpen] = useState(false) + const [key, setKey] = useState("") + + const provider = SUPPORTED_PROVIDERS[0] + + const handleSubmit = () => { + onAdd({ name: provider.name, key }) + setKey("") + setOpen(false) + } + + return ( + + + + + + + Add Google Generative AI Key + + We currently only support{" "} + + Google Generative AI + {" "} + API keys. You can get one from{" "} + + Google AI Studio + + . + + +
    +
    + + +
    +
    + + setKey(e.target.value)} + /> +
    +
    + + + + + + +
    +
    + ) +} + +function ApiKeysPage() { + const { user: currentUser } = useAuth() + const queryClient = useQueryClient() + const { showErrorToast, showSuccessToast } = useCustomToast() + + const { data } = useQuery({ + queryKey: ["api-keys"], + queryFn: () => ApiKeysService.listApiKeys(), + enabled: !!currentUser, + }) + + const createMutation = useMutation({ + mutationFn: (payload: { name?: string; key: string }) => + ApiKeysService.createApiKey({ requestBody: payload }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["api-keys"] }) + showSuccessToast("API key saved") + }, + onError: (err: unknown) => + showErrorToast(err instanceof Error ? err.message : "Unknown error"), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => ApiKeysService.deleteApiKey({ apiKeyId: id }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["api-keys"] }), + onError: (err: unknown) => + showErrorToast(err instanceof Error ? err.message : "Unknown error"), + }) + + if (!currentUser) return null + + const keys = data?.data ?? [] + + return ( +
    +
    +
    +

    API Keys

    +

    + Currently only{" "} + + Google Generative AI + {" "} + keys are supported. Keys are stored securely and never exposed. +

    +
    + createMutation.mutate(payload)} + isPending={createMutation.isPending} + /> +
    + + + + + Name + Created At + Actions + + + + {keys.length === 0 ? ( + + + No API keys configured + + + ) : ( + keys.map((k) => ( + + + {k.name || "(unnamed)"} + + + {k.created_at + ? new Date(k.created_at).toLocaleString() + : "—"} + + + + + + )) + )} + +
    +
    + ) +} + +export default ApiKeysPage diff --git a/test.py b/test.py new file mode 100644 index 0000000000..ad24e93198 --- /dev/null +++ b/test.py @@ -0,0 +1,9 @@ +from google import genai + +# The client gets the API key from the environment variable `GEMINI_API_KEY`. +client = genai.Client() + +response = client.models.generate_content( + model="gemini-3-flash-preview", contents="Explain how AI works in a few words" +) +print(response.text) \ No newline at end of file From da6f1e77365df3fdbba95f0800e1240a0d82f41c Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 19 Apr 2026 15:20:06 +0700 Subject: [PATCH 16/30] update latest before apply design --- .../src/components/Files/FilePreviewModal.tsx | 183 ++++++++++++++++++ frontend/src/components/Files/columns.tsx | 6 +- frontend/src/components/features.tsx | 2 +- frontend/src/components/testimonials.tsx | 2 +- 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/Files/FilePreviewModal.tsx diff --git a/frontend/src/components/Files/FilePreviewModal.tsx b/frontend/src/components/Files/FilePreviewModal.tsx new file mode 100644 index 0000000000..14e6a3000e --- /dev/null +++ b/frontend/src/components/Files/FilePreviewModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react" +import { ChevronDown, DownloadIcon, Eye, Loader2 } from "lucide-react" +import { OpenAPI } from "@/client/core/OpenAPI" +import type { FilePublic } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" + +async function fetchPreviewJson(fileId: string): Promise[]> { + const token = + typeof OpenAPI.TOKEN === "function" + ? await OpenAPI.TOKEN({} as never) + : OpenAPI.TOKEN + + const base = OpenAPI.BASE || "" + const response = await fetch(`${base}/api/v1/files/${fileId}/download?type=json`, { + method: "POST", + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }) + + if (!response.ok) { + throw new Error(`Failed to load preview: ${response.statusText}`) + } + + return response.json() +} + +export function FilePreviewModal({ file }: { file: FilePublic }) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [rows, setRows] = useState[]>([]) + const { mutate: download, isPending: isDownloading } = useDownloadFile() + + const handleOpen = async () => { + setOpen(true) + if (rows.length > 0) return + setLoading(true) + setError(null) + try { + const data = await fetchPreviewJson(file.id) + setRows(Array.isArray(data) ? data : []) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load preview") + } finally { + setLoading(false) + } + } + + const handleDownload = (format: DownloadFormat) => { + download({ fileId: file.id, filename: file.filename, format }) + } + + const cols = rows.length > 0 ? Object.keys(rows[0]) : [] + + return ( + <> + + + + + {/* Header */} +
    +
    +

    Transaction List

    + {!loading && rows.length > 0 && ( + + {rows.length} rows × {cols.length} columns + + )} +
    + + + + + + + handleDownload("xlsx")}> + Excel (.xlsx) + + handleDownload("xlsx-acc-code")}> + Analyze Account Code then Excel (.xlsx) + + handleDownload("csv")}> + CSV (.csv) + + handleDownload("json")}> + JSON (.json) + + handleDownload("html")}> + HTML (.html) + + + +
    + + {/* Body */} +
    + {loading && ( +
    + + Loading preview… +
    + )} + + {error && ( +
    + {error} +
    + )} + + {!loading && !error && rows.length === 0 && ( +
    + No data available. +
    + )} + + {!loading && !error && rows.length > 0 && ( + + + + {cols.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id + + {cols.map((col) => ( + + ))} + + ))} + +
    + {col} +
    + {String(row[col] ?? "—")} +
    + )} +
    +
    +
    + + ) +} \ No newline at end of file diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index 7cc0755cb4..60757d5868 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" +import { FilePreviewModal } from "./FilePreviewModal" function DownloadMenu({ file }: { file: FilePublic }) { const { mutate: download, isPending } = useDownloadFile() @@ -133,7 +134,10 @@ export const columns: ColumnDef[] = [ )} {file.job_status === "done" && ( - + <> + + + )}
    ) diff --git a/frontend/src/components/features.tsx b/frontend/src/components/features.tsx index 8e6744c955..f184b4703d 100644 --- a/frontend/src/components/features.tsx +++ b/frontend/src/components/features.tsx @@ -51,7 +51,7 @@ export default function Features() {
    {features.map((feature, index) => (
    {feature.icon}
    diff --git a/frontend/src/components/testimonials.tsx b/frontend/src/components/testimonials.tsx index 71e153fd60..56232ab876 100644 --- a/frontend/src/components/testimonials.tsx +++ b/frontend/src/components/testimonials.tsx @@ -47,7 +47,7 @@ export default function Testimonials() { >
    {Array.from({ length: testimonial.rating }).map((_, i) => ( - + ))} From b5404dab099e42876c5620d0d274360d53897846 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Mon, 20 Apr 2026 23:44:07 +0700 Subject: [PATCH 17/30] commit --- backend/app/aws/client.py | 10 ++++++ backend/app/files/router.py | 9 ++--- backend/app/files/service.py | 15 +++++---- backend/app/files/utils.py | 9 ++++- .../src/components/Files/FilePreviewModal.tsx | 26 +++------------ frontend/src/hooks/useDownloadFile.ts | 33 +++---------------- frontend/src/lib/fetchWithAuth.ts | 26 +++++++++++++++ 7 files changed, 64 insertions(+), 64 deletions(-) create mode 100644 frontend/src/lib/fetchWithAuth.ts diff --git a/backend/app/aws/client.py b/backend/app/aws/client.py index 3b40a8bd25..f926332ae7 100644 --- a/backend/app/aws/client.py +++ b/backend/app/aws/client.py @@ -51,3 +51,13 @@ def upload_file_to_r2(key: str, data: bytes, content_type: str | None = None, pr if presign: resp["PresignedURL"] = generate_presigned_put_url(key=key, bucket=bucket) return resp + +def download_file_from_r2(key: str, bucket: str | None = None) -> bytes: + bucket = bucket or aws_settings.R2_BUCKET_NAME + if not bucket: + raise RuntimeError("S3 bucket not configured") + + client = get_s3_client() + response = client.get_object(Bucket=bucket, Key=key) + print('S3 get_object response metadata:', response.get("ResponseMetadata", {})) + return response["Body"].read() \ No newline at end of file diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 9d7f8a77ac..67b6b1f9c5 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -1,7 +1,4 @@ -import os -import tempfile import uuid -from urllib.parse import quote from fastapi import APIRouter, HTTPException, Response, UploadFile from sqlalchemy import desc @@ -15,7 +12,7 @@ from app.files.schemas import FileCreate, FilePublic, FilesPublic, FilesStatusRequest from app.files.service import ( download_file, - get_gemini_response_for_file, download_file_with_account_code, + download_file_with_account_code, ) from app.ocrs.service import get_ocr_job_status, post_ocr_jobs @@ -115,7 +112,7 @@ def download_table_excel_file(file_id: uuid.UUID, type: str, session: SessionDep raise HTTPException(status_code=403, detail="Not authorized to access this file") if file.job_status != "done": raise HTTPException(status_code=400, detail="OCR job is not done yet") - + logger.info(f"Preparing to stream file {file_id} for user {user.email} with requested type {type}") excel_bytes, content_disposition = download_file(file=file, user=user, type=type) media_type = { "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -123,7 +120,7 @@ def download_table_excel_file(file_id: uuid.UUID, type: str, session: SessionDep "json": "application/json", "html": "text/html", }.get(type, "application/octet-stream") - + logger.info(f"Streaming file {file_id} to user {user.email} with media type {media_type}") return Response( content=excel_bytes, media_type=media_type, diff --git a/backend/app/files/service.py b/backend/app/files/service.py index 76e98066f2..dce75bad56 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -7,18 +7,18 @@ from sqlmodel import Session from app.api_keys.crud import get_api_key_by_user -from app.aws.client import generate_presigned_put_url +from app.aws.client import download_file_from_r2, generate_presigned_put_url from app.aws.config import aws_settings from app.core.config import settings from app.files.dependencies import CurrentUser from app.files.models import File from app.files.strategies import DOWNLOAD_STRATEGIES -from app.files.utils import get_df_from_result_json +from app.files.utils import get_df_from_json_bytes, get_df_from_result_json user_instruction = ( - "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung trong cột thứ 2 để xác định giao dịch " + "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung để xác định giao dịch (Bạn phải tự xác định cột chứa nội dung giao dịch)" "này thuộc mã tài khoản kế toán nào (mã này được lấy từ thị trường Việt Nam). Sau đó trả ra " - "cho tôi file mới có thêm cột mã tk, và tên tk ở cuối.\n\n" + "cho tôi file mới có thêm cột mã tk, và tên tk ở cuối. Nếu nội dung chuyển khoản không chắc chắn, hãy bỏ trống\n\n" "Dưới đây là nội dung file (nguyên văn). Hãy trả lại CHÍNH XÁC NỘI DUNG file mới dưới dạng CSV hoặc plain text, " "không thêm giải thích, chú thích hay văn bản khác. Chỉ output nội dung file mới.\n\n" ) @@ -39,9 +39,10 @@ def download_file(file: File, user: CurrentUser, type: str = "xlsx") -> tuple[by json_key = f"{user.email}/{file.id}/result.json" - presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) - df: DataFrame = get_df_from_result_json(presigned_url) - + # presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) + # df: DataFrame = get_df_from_result_json(presigned_url) + json_file = download_file_from_r2(key=json_key, bucket=aws_settings.R2_BUCKET_NAME) + df = get_df_from_json_bytes(json_file) safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename data_bytes, content_disposition = strategy.convert(df, safe_name) diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py index 9617f39ca5..c347279214 100644 --- a/backend/app/files/utils.py +++ b/backend/app/files/utils.py @@ -1,3 +1,4 @@ +import json from io import StringIO import pandas as pd @@ -39,4 +40,10 @@ def get_df_from_result_json(url) -> DataFrame: data = res.json() - return extract_tables_from_ocr(data) \ No newline at end of file + return extract_tables_from_ocr(data) + +def get_df_from_json_bytes(json_bytes: bytes) -> DataFrame: + + json_data = json.loads(json_bytes.decode("utf-8")) + + return extract_tables_from_ocr(json_data) \ No newline at end of file diff --git a/frontend/src/components/Files/FilePreviewModal.tsx b/frontend/src/components/Files/FilePreviewModal.tsx index 14e6a3000e..1a6fe81592 100644 --- a/frontend/src/components/Files/FilePreviewModal.tsx +++ b/frontend/src/components/Files/FilePreviewModal.tsx @@ -1,11 +1,11 @@ import { useState } from "react" import { ChevronDown, DownloadIcon, Eye, Loader2 } from "lucide-react" -import { OpenAPI } from "@/client/core/OpenAPI" -import type { FilePublic } from "@/client" +import { FilesService, type FilePublic } from "@/client" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, + DialogTitle, } from "@/components/ui/dialog" import { DropdownMenu, @@ -16,24 +16,7 @@ import { import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" async function fetchPreviewJson(fileId: string): Promise[]> { - const token = - typeof OpenAPI.TOKEN === "function" - ? await OpenAPI.TOKEN({} as never) - : OpenAPI.TOKEN - - const base = OpenAPI.BASE || "" - const response = await fetch(`${base}/api/v1/files/${fileId}/download?type=json`, { - method: "POST", - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - - if (!response.ok) { - throw new Error(`Failed to load preview: ${response.statusText}`) - } - - return response.json() + return FilesService.downloadTableExcelFile({ fileId, type: "json" }) as Promise[]> } export function FilePreviewModal({ file }: { file: FilePublic }) { @@ -77,7 +60,8 @@ export function FilePreviewModal({ file }: { file: FilePublic }) { - + + File Preview {/* Header */}
    diff --git a/frontend/src/hooks/useDownloadFile.ts b/frontend/src/hooks/useDownloadFile.ts index 80269f01b4..e487d9c3c5 100644 --- a/frontend/src/hooks/useDownloadFile.ts +++ b/frontend/src/hooks/useDownloadFile.ts @@ -1,27 +1,7 @@ import { useMutation } from "@tanstack/react-query" -import { OpenAPI } from "@/client/core/OpenAPI" import useCustomToast from "./useCustomToast" -import { ApiError } from "@/client" - -async function fetchDownload(url: string): Promise { - const token = - typeof OpenAPI.TOKEN === "function" - ? await OpenAPI.TOKEN({} as never) - : OpenAPI.TOKEN - - const response = await fetch(url, { - method: "POST", - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }) - console.log('response', response) - if (!response.ok) { - throw new Error(`Download failed: ${response.statusText}`) - } - - return response.blob() -} +import type { ApiError } from "@/client" +import { fetchBlobWithAuth } from "@/lib/fetchWithAuth" function triggerBlobDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) @@ -47,18 +27,13 @@ export function useDownloadFile() { return useMutation({ mutationFn: async ({ fileId, filename, format }: DownloadFileParams) => { - const base = OpenAPI.BASE || "" const safeName = filename.replace(/\.[^.]+$/, "") if (format === "xlsx-acc-code") { - const blob = await fetchDownload( - `${base}/api/v1/files/${fileId}/download/new`, - ) + const blob = await fetchBlobWithAuth(`/api/v1/files/${fileId}/download/new`) triggerBlobDownload(blob, `${safeName}_tables_with_acc_codes.xlsx`) } else { - const blob = await fetchDownload( - `${base}/api/v1/files/${fileId}/download?type=${format}`, - ) + const blob = await fetchBlobWithAuth(`/api/v1/files/${fileId}/download?type=${format}`) triggerBlobDownload(blob, `${safeName}_tables.${format}`) } }, diff --git a/frontend/src/lib/fetchWithAuth.ts b/frontend/src/lib/fetchWithAuth.ts new file mode 100644 index 0000000000..48df0ea928 --- /dev/null +++ b/frontend/src/lib/fetchWithAuth.ts @@ -0,0 +1,26 @@ +import axios from "axios" +import { OpenAPI } from "@/client/core/OpenAPI" +import { FilesService } from "@/client" + +async function getAuthHeaders(): Promise> { + const token = + typeof OpenAPI.TOKEN === "function" + ? await OpenAPI.TOKEN({} as never) + : OpenAPI.TOKEN + return token ? { Authorization: `Bearer ${token}` } : {} +} + +/** + * POST to a path and return the response as a Blob. + * Used for binary file downloads where FilesService cannot be used + * directly (it doesn't set responseType: 'blob'). + */ +export async function fetchBlobWithAuth(path: string): Promise { + const base = OpenAPI.BASE || "" + const headers = await getAuthHeaders() + const response = await axios.post(`${base}${path}`, null, { + responseType: "blob", + headers, + }) + return response.data +} From 562e9f4d41e19599664d7555345c504ae260a08b Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Thu, 23 Apr 2026 21:57:51 +0700 Subject: [PATCH 18/30] update code --- backend/app/files/strategies.py | 12 +++++- backend/app/files/utils.py | 5 ++- .../src/components/Files/FilePreviewModal.tsx | 39 ++++++++++++++----- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/backend/app/files/strategies.py b/backend/app/files/strategies.py index 0ed42feb5e..1ad5b29eb9 100644 --- a/backend/app/files/strategies.py +++ b/backend/app/files/strategies.py @@ -36,10 +36,18 @@ def encode_filename(self, filename: str) -> str: return quote(filename, safe="") def get_content_disposition(self, filename: str) -> str: - """Generate a Content-Disposition header value with both filename and filename*.""" + """Generate a Content-Disposition header value with both filename and filename*. + + The legacy ``filename`` parameter is restricted to ASCII/latin-1 so that + HTTP headers remain valid regardless of the server encoding. The full + Unicode filename is carried by the RFC 5987 ``filename*`` parameter. + """ encoded_filename = self.encode_filename(filename) + # Strip / replace non-ASCII chars for the legacy filename= field so the + # header value never triggers a UnicodeEncodeError in latin-1. + ascii_filename = filename.encode("ascii", errors="replace").decode("ascii") return ( - f"attachment; filename=\"{filename}\"; " + f"attachment; filename=\"{ascii_filename}\"; " f"filename*=UTF-8''{encoded_filename}" ) diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py index c347279214..9711ced3bf 100644 --- a/backend/app/files/utils.py +++ b/backend/app/files/utils.py @@ -27,8 +27,11 @@ def extract_tables_from_ocr(data) -> pd.DataFrame: pass if not all_dfs: raise Exception("No tables found") - + # Merge all tables + for i in range(1, len(all_dfs)): + all_dfs[i].drop(0) + merged = pd.concat(all_dfs, ignore_index=True) return merged diff --git a/frontend/src/components/Files/FilePreviewModal.tsx b/frontend/src/components/Files/FilePreviewModal.tsx index 1a6fe81592..33704974f3 100644 --- a/frontend/src/components/Files/FilePreviewModal.tsx +++ b/frontend/src/components/Files/FilePreviewModal.tsx @@ -33,6 +33,7 @@ export function FilePreviewModal({ file }: { file: FilePublic }) { setError(null) try { const data = await fetchPreviewJson(file.id) + data[0] && console.log("Preview data sample:", data[0]) setRows(Array.isArray(data) ? data : []) } catch (err) { setError(err instanceof Error ? err.message : "Failed to load preview") @@ -60,7 +61,7 @@ export function FilePreviewModal({ file }: { file: FilePublic }) { - + File Preview {/* Header */}
    @@ -135,25 +136,43 @@ export function FilePreviewModal({ file }: { file: FilePublic }) { - {cols.map((col) => ( + {rows.length > 0 && Object.values(rows[0]).map((col) => ( ))} - {rows.map((row, i) => ( + { + rows.length > 1 && rows.slice(1).map((row, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id - {cols.map((col) => ( - - ))} + {cols.map((col) => { + const val = row[col] + let display: string + if (val == null || String(val).trim() === "") { + display = "—" + } else { + const str = String(val).trim() + // strip thousands separators then check if it's a pure number string + const stripped = str.replace(/,/g, "") + const num = Number(stripped) + if (!Number.isNaN(num) && /^-?\d+(\.\d+)?$/.test(stripped)) { + display = num.toLocaleString() + } else { + display = str + } + } + return ( + + ) + })} ))} From aae9b1154b3c38b0017aaa161ee4bd4c4032d47e Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Fri, 24 Apr 2026 00:24:27 +0700 Subject: [PATCH 19/30] add vnpay --- backend/app/api/main.py | 2 + backend/app/api/routes/topup.py | 136 ++++++++++ backend/app/core/config.py | 4 +- backend/app/vnpay/__init__.py | 27 ++ backend/app/vnpay/client.py | 244 ++++++++++++++++++ backend/app/vnpay/config.py | 43 +++ backend/app/vnpay/constants.py | 106 ++++++++ backend/app/vnpay/exceptions.py | 18 ++ backend/app/vnpay/schemas.py | 154 +++++++++++ frontend/src/client/schemas.gen.ts | 67 +++++ frontend/src/client/sdk.gen.ts | 54 +++- frontend/src/client/types.gen.ts | 30 +++ .../components/Common/LanguageSwitcher.tsx | 2 +- .../src/components/Files/FilePreviewModal.tsx | 102 ++++---- frontend/src/components/Files/columns.tsx | 2 +- .../src/components/Sidebar/AppSidebar.tsx | 3 +- frontend/src/components/footer.tsx | 12 +- frontend/src/components/how-it-works.tsx | 4 +- frontend/src/components/testimonials.tsx | 5 +- frontend/src/hooks/useDownloadFile.ts | 12 +- frontend/src/lib/fetchWithAuth.ts | 1 - frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/_layout/api-keys.tsx | 4 +- frontend/src/routes/_layout/topup.tsx | 191 ++++++++++++++ 24 files changed, 1178 insertions(+), 66 deletions(-) create mode 100644 backend/app/api/routes/topup.py create mode 100644 backend/app/vnpay/__init__.py create mode 100644 backend/app/vnpay/client.py create mode 100644 backend/app/vnpay/config.py create mode 100644 backend/app/vnpay/constants.py create mode 100644 backend/app/vnpay/exceptions.py create mode 100644 backend/app/vnpay/schemas.py create mode 100644 frontend/src/routes/_layout/topup.tsx diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 7b23e448f0..135ee1af44 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from app.api.routes.topup import router as topup_router from app.api.routes.utils import router as utils_router from app.api_keys.router import router as api_keys_router from app.auth.router import router as login_router @@ -17,6 +18,7 @@ api_router.include_router(files_router) api_router.include_router(storages_router) api_router.include_router(api_keys_router) +api_router.include_router(topup_router) if settings.ENVIRONMENT == "local": from app.api.routes.private import router as private_router diff --git a/backend/app/api/routes/topup.py b/backend/app/api/routes/topup.py new file mode 100644 index 0000000000..09c1ba64c7 --- /dev/null +++ b/backend/app/api/routes/topup.py @@ -0,0 +1,136 @@ +from app.backend_pre_start import logger +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from app.auth.dependencies import CurrentUser +from app.vnpay import BankCode, PaymentRequest, VNPayClient, VNPayConfig +from app.vnpay.constants import OrderType + +router = APIRouter(prefix="/topup", tags=["topup"]) + +TOPUP_PACKAGES = [ + {"id": "20k", "amount": 20_000, "label": "20,000 VND"}, + {"id": "50k", "amount": 50_000, "label": "50,000 VND"}, + {"id": "100k", "amount": 100_000, "label": "100,000 VND"}, + {"id": "200k", "amount": 200_000, "label": "200,000 VND"}, + {"id": "500k", "amount": 500_000, "label": "500,000 VND"}, + {"id": "1000k", "amount": 1_000_000, "label": "1,000,000 VND"}, + {"id": "2000k", "amount": 2_000_000, "label": "2,000,000 VND"}, + {"id": "5000k", "amount": 5_000_000, "label": "5,000,000 VND"}, + {"id": "10000k", "amount": 10_000_000, "label": "10,000,000 VND"}, +] + + +class TopupPackage(BaseModel): + id: str + amount: int + label: str + + +class TopupPackagesResponse(BaseModel): + packages: list[TopupPackage] + + +class CreatePaymentRequest(BaseModel): + amount: int + + +class CreatePaymentResponse(BaseModel): + payment_url: str + txn_ref: str + amount: int + + +def _get_vnpay_client(return_url: str) -> VNPayClient: + from app.core.config import settings + + config = VNPayConfig( + tmn_code=getattr(settings, "VNPAY_TMN_CODE", "1PBWTG40"), + hash_secret=getattr(settings, "VNPAY_HASH_SECRET", "DEMOSECRET"), + return_url=return_url, + ) + logger.info(f"Initialized VNPayClient with TMN code: {config.tmn_code}, Return URL: {config.return_url}") + return VNPayClient(config) + + +@router.get("/packages", response_model=TopupPackagesResponse) +def get_topup_packages(_current_user: CurrentUser) -> Any: + """Return the list of available top-up packages.""" + return TopupPackagesResponse( + packages=[ + TopupPackage(id=str(p["id"]), amount=int(p["amount"]), label=str(p["label"])) # type: ignore[arg-type] + for p in TOPUP_PACKAGES + ] + ) + + +@router.post("/create-payment", response_model=CreatePaymentResponse) +def create_topup_payment( + body: CreatePaymentRequest, + request: Request, + current_user: CurrentUser, +) -> Any: + """ + Generate a VNPAY payment URL for the selected top-up package. + The client should redirect the user (or display a QR code) using + the returned ``payment_url``. + """ + # Validate amount is one of the allowed packages + allowed_amounts = {p["amount"] for p in TOPUP_PACKAGES} + if body.amount not in allowed_amounts: + raise HTTPException( + status_code=400, + detail=f"Invalid topup amount. Allowed: {sorted(allowed_amounts)}", + ) + + txn_ref = f"TOPUP-{current_user.id}-{uuid.uuid4().hex[:8].upper()}" + + # Build the return URL from the incoming request's base URL + base_url = str(request.base_url).rstrip("/") + return_url = f"{base_url}/api/v1/topup/return" + + client = _get_vnpay_client(return_url) + + # Attempt to get the real client IP + client_ip = ( + request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + or request.client.host + if request.client + else "127.0.0.1" + ) + + payment_request = PaymentRequest( + txn_ref=txn_ref, + amount=body.amount, + order_info=f"Nap tien tai khoan {current_user.email}", + order_type=OrderType.TOPUP, + ip_addr=client_ip, + bank_code=BankCode.VNPAYQR, + ) + + response = client.create_payment_url(payment_request) + + return CreatePaymentResponse( + payment_url=response.payment_url, + txn_ref=response.txn_ref, + amount=response.amount, + ) + + +@router.get("/return") +def topup_return(request: Request) -> Any: + """ + VNPAY ReturnURL handler – VNPAY redirects the customer's browser here + after payment. In production you would verify the signature, update + the user balance, and redirect to the front-end result page. + """ + params = dict(request.query_params) + vnp_response_code = params.get("vnp_ResponseCode", "") + txn_ref = params.get("vnp_TxnRef", "") + + if vnp_response_code == "00": + return {"status": "success", "txn_ref": txn_ref, "message": "Payment successful"} + return {"status": "failed", "txn_ref": txn_ref, "message": "Payment failed", "code": vnp_response_code} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c9311a9112..c5d1c5447c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -129,7 +129,7 @@ def _enforce_non_default_secrets(self) -> Self: OCR_JOB_POLLING_INTERVAL: int = 5 # in seconds OCR_MODEL: str = "PaddleOCR-VL" - GMN_API_KEY: str | None = None - + VNPAY_TMN_CODE: str | None = None + VNPAY_HASH_SECRET: str | None = None settings = Settings() # type: ignore diff --git a/backend/app/vnpay/__init__.py b/backend/app/vnpay/__init__.py new file mode 100644 index 0000000000..0bcaabacf9 --- /dev/null +++ b/backend/app/vnpay/__init__.py @@ -0,0 +1,27 @@ +from .client import VNPayClient +from .config import VNPayConfig +from .constants import BankCode, ResponseCode, TransactionStatus +from .exceptions import InvalidSignatureError, OrderNotFoundError, VNPayException +from .schemas import ( + IPNRequest, + IPNResponse, + PaymentRequest, + PaymentResponse, + ReturnURLRequest, +) + +__all__ = [ + "VNPayClient", + "VNPayConfig", + "PaymentRequest", + "PaymentResponse", + "IPNRequest", + "IPNResponse", + "ReturnURLRequest", + "ResponseCode", + "TransactionStatus", + "BankCode", + "VNPayException", + "InvalidSignatureError", + "OrderNotFoundError", +] diff --git a/backend/app/vnpay/client.py b/backend/app/vnpay/client.py new file mode 100644 index 0000000000..0f4ae5c5f1 --- /dev/null +++ b/backend/app/vnpay/client.py @@ -0,0 +1,244 @@ +""" +VNPay PAY API client. + +Usage example:: + + from app.vnpay import VNPayClient, VNPayConfig, PaymentRequest + + config = VNPayConfig( + tmn_code="YOUR_TMN_CODE", + hash_secret="YOUR_HASH_SECRET", + return_url="https://yourdomain.vn/payment/return", + ) + client = VNPayClient(config) + + # 1. Create a payment URL and redirect the customer to it + response = client.create_payment_url( + PaymentRequest( + txn_ref="ORDER-001", + amount=150000, + order_info="Thanh toan don hang ORDER-001", + ip_addr="127.0.0.1", + ) + ) + print(response.payment_url) + + # 2. Handle the IPN callback (server-to-server) + ipn_data = IPNRequest(**request.query_params) + ipn_response = client.verify_ipn(ipn_data) + return ipn_response.model_dump() + + # 3. Handle the ReturnURL callback (browser redirect) + return_data = ReturnURLRequest(**request.query_params) + is_valid, parsed = client.verify_return_url(return_data) +""" + +import hashlib +import hmac +import urllib.parse +from datetime import datetime, timedelta, timezone + +from .config import VNPayConfig +from .constants import IPNRspCode +from .exceptions import InvalidSignatureError +from .schemas import ( + IPNRequest, + IPNResponse, + PaymentRequest, + PaymentResponse, + ReturnURLRequest, +) + +# UTC+7 timezone (Vietnam Standard Time) +_VST = timezone(timedelta(hours=7)) +_DATE_FMT = "%Y%m%d%H%M%S" + + +def _vst_now() -> datetime: + return datetime.now(_VST) + + +def _fmt_date(dt: datetime) -> str: + """Format a datetime to VNPAY's yyyyMMddHHmmss format in VST.""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=_VST) + return dt.astimezone(_VST).strftime(_DATE_FMT) + + +def _build_query_string(params: dict[str, str]) -> tuple[str, str]: + """ + Sort params by key, then build both: + - ``hash_data``: the string to sign (urlencode each key=value pair) + - ``query_string``: full query string for the payment URL + """ + sorted_params = sorted(params.items()) + parts: list[str] = [ + f"{urllib.parse.quote_plus(k)}={urllib.parse.quote_plus(v)}" + for k, v in sorted_params + if v # skip blank values + ] + joined = "&".join(parts) + return joined, joined # both hash_data and query_string are the same format + + +def _hmac_sha512(secret: str, data: str) -> str: + return hmac.new( + secret.encode("utf-8"), + data.encode("utf-8"), + hashlib.sha512, + ).hexdigest() + + +def _verify_signature(params: dict[str, str], secure_hash: str, secret: str) -> bool: + """Verify HMAC-SHA512 signature received from VNPAY.""" + filtered = {k: v for k, v in params.items() if k != "vnp_SecureHash"} + data, _ = _build_query_string(filtered) + expected = _hmac_sha512(secret, data) + return hmac.compare_digest(expected, secure_hash) + + +class VNPayClient: + """ + High-level client for the VNPAY PAY API. + + All methods are synchronous and stateless; the client holds only + the ``VNPayConfig`` configuration object. + """ + + def __init__(self, config: VNPayConfig) -> None: + self.config = config + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def create_payment_url(self, request: PaymentRequest) -> PaymentResponse: + """ + Build and return a signed VNPAY payment URL. + + The customer should be redirected to ``PaymentResponse.payment_url``. + """ + now = _vst_now() + expire = ( + request.expire_date + if request.expire_date is not None + else now + timedelta(minutes=self.config.expire_minutes) + ) + + params: dict[str, str] = { + "vnp_Version": self.config.version, + "vnp_Command": "pay", + "vnp_TmnCode": self.config.tmn_code, + "vnp_Amount": str(request.amount * 100), + "vnp_CreateDate": _fmt_date(now), + "vnp_CurrCode": self.config.curr_code, + "vnp_IpAddr": request.ip_addr, + "vnp_Locale": ( + request.locale.value if request.locale else self.config.locale + ), + "vnp_OrderInfo": request.order_info, + "vnp_OrderType": request.order_type.value, + "vnp_ReturnUrl": self.config.return_url, + "vnp_TxnRef": request.txn_ref, + "vnp_ExpireDate": _fmt_date(expire), + } + + # Optional params + if request.bank_code: + params["vnp_BankCode"] = request.bank_code.value + if request.bill_mobile: + params["vnp_Bill_Mobile"] = request.bill_mobile + if request.bill_email: + params["vnp_Bill_Email"] = request.bill_email + if request.bill_first_name: + params["vnp_Bill_FirstName"] = request.bill_first_name + if request.bill_last_name: + params["vnp_Bill_LastName"] = request.bill_last_name + if request.bill_address: + params["vnp_Bill_Address"] = request.bill_address + if request.bill_city: + params["vnp_Bill_City"] = request.bill_city + if request.bill_country: + params["vnp_Bill_Country"] = request.bill_country + if request.bill_state: + params["vnp_Bill_State"] = request.bill_state + if request.inv_phone: + params["vnp_Inv_Phone"] = request.inv_phone + if request.inv_email: + params["vnp_Inv_Email"] = request.inv_email + if request.inv_customer: + params["vnp_Inv_Customer"] = request.inv_customer + if request.inv_address: + params["vnp_Inv_Address"] = request.inv_address + if request.inv_company: + params["vnp_Inv_Company"] = request.inv_company + if request.inv_taxcode: + params["vnp_Inv_Taxcode"] = request.inv_taxcode + if request.inv_type: + params["vnp_Inv_Type"] = request.inv_type + + hash_data, query_string = _build_query_string(params) + secure_hash = _hmac_sha512(self.config.hash_secret, hash_data) + + payment_url = ( + f"{self.config.payment_url}?{query_string}" + f"&vnp_SecureHash={secure_hash}" + ) + + return PaymentResponse( + payment_url=payment_url, + txn_ref=request.txn_ref, + amount=request.amount, + created_at=now, + ) + + def verify_ipn(self, ipn: IPNRequest) -> IPNResponse: + """ + Validate an IPN request sent by VNPAY to the merchant's IPN URL. + + Returns an ``IPNResponse`` that the merchant **must** send back as + a JSON response to VNPAY. + + Raises ``InvalidSignatureError`` only in unexpected situations; + invalid signatures are returned as ``RspCode="97"`` per VNPAY spec. + """ + raw = ipn.model_dump() + secure_hash = raw.pop("vnp_SecureHash", "") + str_params = {k: str(v) for k, v in raw.items() if v is not None} + + if not _verify_signature(str_params, secure_hash, self.config.hash_secret): + return IPNResponse(RspCode=IPNRspCode.INVALID_SIGNATURE, Message="Invalid signature") + + return IPNResponse(RspCode=IPNRspCode.CONFIRMED, Message="Confirm Success") + + def verify_return_url(self, data: ReturnURLRequest) -> tuple[bool, ReturnURLRequest]: + """ + Validate the ReturnURL callback that VNPAY sends back to the customer's + browser after payment. + + Returns ``(is_valid, data)``. When ``is_valid`` is ``False`` the + checksum did not match; **do not** trust the payment result. + + Raises ``InvalidSignatureError`` if you prefer exception-based flow — + pass ``raise_on_invalid=True``:: + + is_valid, parsed = client.verify_return_url(data) + """ + raw = data.model_dump() + secure_hash = raw.pop("vnp_SecureHash", "") + str_params = {k: str(v) for k, v in raw.items() if v is not None} + + is_valid = _verify_signature(str_params, secure_hash, self.config.hash_secret) + return is_valid, data + + def verify_return_url_strict(self, data: ReturnURLRequest) -> ReturnURLRequest: + """ + Same as ``verify_return_url`` but raises ``InvalidSignatureError`` + if the checksum does not match. + """ + is_valid, result = self.verify_return_url(data) + if not is_valid: + raise InvalidSignatureError( + f"VNPAY ReturnURL signature mismatch for txn_ref={data.vnp_TxnRef}" + ) + return result diff --git a/backend/app/vnpay/config.py b/backend/app/vnpay/config.py new file mode 100644 index 0000000000..70d002ff40 --- /dev/null +++ b/backend/app/vnpay/config.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + + +@dataclass +class VNPayConfig: + """ + Holds all credentials and endpoint URLs needed to talk to VNPAY. + + Sandbox defaults are pre-filled so you can get started quickly. + Replace them with your production values before going live. + """ + + # Merchant credentials (provided by VNPAY after registration) + tmn_code: str + hash_secret: str + + # Merchant's return URLs + return_url: str + + # VNPAY endpoints + payment_url: str = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html" + api_url: str = "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction" + bank_list_url: str = "https://sandbox.vnpayment.vn/qrpayauth/api/merchant/get_bank_list" + + # API version + version: str = "2.1.0" + + # Default locale shown on VNPAY's payment page ("vn" or "en") + locale: str = "vn" + + # Default currency (only VND is supported at this time) + curr_code: str = "VND" + + # Payment expiry window in minutes (default: 15 minutes) + expire_minutes: int = 15 + + def __post_init__(self) -> None: + if not self.tmn_code: + raise ValueError("tmn_code must not be empty.") + if not self.hash_secret: + raise ValueError("hash_secret must not be empty.") + if not self.return_url: + raise ValueError("return_url must not be empty.") diff --git a/backend/app/vnpay/constants.py b/backend/app/vnpay/constants.py new file mode 100644 index 0000000000..ee1198c3d3 --- /dev/null +++ b/backend/app/vnpay/constants.py @@ -0,0 +1,106 @@ +from enum import StrEnum + + +class BankCode(StrEnum): + """Supported payment method / bank codes.""" + + VNPAYQR = "VNPAYQR" # QR code scan + VNBANK = "VNBANK" # Domestic ATM / internet banking + INTCARD = "INTCARD" # International card (Visa/Master/JCB) + + +class OrderType(StrEnum): + """Product / service category codes defined by VNPAY.""" + + FASHION = "fashion" + FOOD = "food" + OTHERS = "other" + TOPUP = "topup" + TRAVEL = "travel" + EDUCATION = "edu" + COSMETICS = "cos" + TECHNOLOGY = "tec" + + +class Locale(StrEnum): + VIETNAMESE = "vn" + ENGLISH = "en" + + +class ResponseCode(StrEnum): + """ + ``vnp_ResponseCode`` values returned by VNPAY through IPN / ReturnURL. + """ + + SUCCESS = "00" + SUSPICIOUS_TRANSACTION = "07" + NOT_REGISTERED_INTERNET_BANKING = "09" + WRONG_CARD_INFO_3_TIMES = "10" + PAYMENT_EXPIRED = "11" + CARD_LOCKED = "12" + WRONG_OTP = "13" + TRANSACTION_CANCELLED = "24" + INSUFFICIENT_BALANCE = "51" + DAILY_LIMIT_EXCEEDED = "65" + BANK_MAINTENANCE = "75" + WRONG_PAYMENT_PASSWORD = "79" + UNKNOWN_ERROR = "99" + + @property + def description(self) -> str: + _MAP: dict[str, str] = { + "00": "Giao dịch thành công", + "07": "Trừ tiền thành công. Giao dịch bị nghi ngờ (lừa đảo, giao dịch bất thường).", + "09": "Thẻ/Tài khoản chưa đăng ký dịch vụ InternetBanking.", + "10": "Xác thực thông tin thẻ/tài khoản không đúng quá 3 lần.", + "11": "Đã hết hạn chờ thanh toán.", + "12": "Thẻ/Tài khoản bị khóa.", + "13": "Nhập sai mật khẩu xác thực giao dịch (OTP).", + "24": "Khách hàng hủy giao dịch.", + "51": "Tài khoản không đủ số dư.", + "65": "Vượt quá hạn mức giao dịch trong ngày.", + "75": "Ngân hàng thanh toán đang bảo trì.", + "79": "Nhập sai mật khẩu thanh toán quá số lần quy định.", + "99": "Lỗi không xác định.", + } + return _MAP.get(self.value, "Lỗi không xác định.") + + +class TransactionStatus(StrEnum): + """ + ``vnp_TransactionStatus`` values describing VNPAY-side transaction state. + """ + + SUCCESS = "00" + PENDING = "01" + ERROR = "02" + REVERSED = "04" + REFUND_PROCESSING = "05" + REFUND_SENT_TO_BANK = "06" + SUSPECTED_FRAUD = "07" + REFUND_REJECTED = "09" + + @property + def description(self) -> str: + _MAP: dict[str, str] = { + "00": "Giao dịch thành công", + "01": "Giao dịch chưa hoàn tất", + "02": "Giao dịch bị lỗi", + "04": "Giao dịch đảo (đã trừ tiền nhưng chưa thành công ở VNPAY)", + "05": "VNPAY đang xử lý hoàn tiền", + "06": "VNPAY đã gửi yêu cầu hoàn tiền sang Ngân hàng", + "07": "Giao dịch bị nghi ngờ gian lận", + "09": "Giao dịch hoàn trả bị từ chối", + } + return _MAP.get(self.value, "Trạng thái không xác định.") + + +class IPNRspCode(StrEnum): + """Response codes that the merchant must send back to VNPAY on IPN.""" + + CONFIRMED = "00" # Successfully updated – VNPAY stops retrying + ORDER_NOT_FOUND = "01" # Order not found – VNPAY retries + ALREADY_CONFIRMED = "02" # Already confirmed – VNPAY stops retrying + INVALID_AMOUNT = "04" # Amount mismatch – VNPAY retries + INVALID_SIGNATURE = "97" # Checksum failed – VNPAY retries + UNKNOWN_ERROR = "99" # Unknown error – VNPAY retries diff --git a/backend/app/vnpay/exceptions.py b/backend/app/vnpay/exceptions.py new file mode 100644 index 0000000000..e2d7c16579 --- /dev/null +++ b/backend/app/vnpay/exceptions.py @@ -0,0 +1,18 @@ +class VNPayException(Exception): + """Base exception for VNPay library.""" + + +class InvalidSignatureError(VNPayException): + """Raised when the HMAC-SHA512 checksum does not match.""" + + +class OrderNotFoundError(VNPayException): + """Raised when the order referenced by vnp_TxnRef cannot be found.""" + + +class InvalidAmountError(VNPayException): + """Raised when the payment amount does not match the order amount.""" + + +class OrderAlreadyConfirmedError(VNPayException): + """Raised when the IPN for an already-confirmed order is received.""" diff --git a/backend/app/vnpay/schemas.py b/backend/app/vnpay/schemas.py new file mode 100644 index 0000000000..2d66207d2b --- /dev/null +++ b/backend/app/vnpay/schemas.py @@ -0,0 +1,154 @@ +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + +from .constants import BankCode, Locale, OrderType + + +# --------------------------------------------------------------------------- +# Payment request (merchant → VNPAY) +# --------------------------------------------------------------------------- + + +class PaymentRequest(BaseModel): + """ + Parameters needed to build a VNPAY payment URL. + + ``amount`` is in **VND** (integer). The library will multiply by 100 + before sending to VNPAY as required by the API spec. + """ + + txn_ref: str = Field( + ..., + description="Unique order / transaction reference on the merchant side.", + max_length=100, + ) + amount: int = Field( + ..., + description="Amount in VND (not multiplied by 100 yet).", + gt=0, + ) + order_info: str = Field( + ..., + description="Payment description (no special characters, no Vietnamese diacritics).", + max_length=255, + ) + order_type: OrderType = Field( + default=OrderType.OTHERS, + description="Product category code.", + ) + ip_addr: str = Field( + ..., + description="IP address of the customer making the payment.", + ) + bank_code: BankCode | None = Field( + default=None, + description="Pre-select a payment method. Leave None to let the customer choose.", + ) + locale: Locale | None = Field( + default=None, + description="Override the default locale (vn/en).", + ) + expire_date: datetime | None = Field( + default=None, + description="Override the default payment expiry time (UTC+7).", + ) + + # Optional billing info + bill_mobile: str | None = Field(default=None, max_length=20) + bill_email: str | None = Field(default=None, max_length=255) + bill_first_name: str | None = Field(default=None, max_length=255) + bill_last_name: str | None = Field(default=None, max_length=255) + bill_address: str | None = Field(default=None, max_length=255) + bill_city: str | None = Field(default=None, max_length=255) + bill_country: str | None = Field(default=None, max_length=2) + bill_state: str | None = Field(default=None, max_length=255) + + # Optional invoice info + inv_phone: str | None = Field(default=None, max_length=20) + inv_email: str | None = Field(default=None, max_length=255) + inv_customer: str | None = Field(default=None, max_length=255) + inv_address: str | None = Field(default=None, max_length=255) + inv_company: str | None = Field(default=None, max_length=255) + inv_taxcode: str | None = Field(default=None, max_length=20) + inv_type: str | None = Field(default=None, max_length=20) + + @field_validator("txn_ref") + @classmethod + def txn_ref_no_spaces(cls, v: str) -> str: + if " " in v: + raise ValueError("txn_ref must not contain spaces.") + return v + + +# --------------------------------------------------------------------------- +# Payment response (VNPAY → merchant, via ReturnURL or IPN) +# --------------------------------------------------------------------------- + + +class _VNPayCallbackBase(BaseModel): + """Fields shared by both IPN and ReturnURL callbacks.""" + + vnp_TmnCode: str + vnp_Amount: int + vnp_BankCode: str + vnp_BankTranNo: str | None = None + vnp_CardType: str | None = None + vnp_PayDate: str | None = None + vnp_OrderInfo: str + vnp_TransactionNo: str + vnp_ResponseCode: str + vnp_TransactionStatus: str + vnp_TxnRef: str + vnp_SecureHash: str + + @property + def amount_vnd(self) -> int: + """Returns the real VND amount (VNPAY sends amount × 100).""" + return self.vnp_Amount // 100 + + @property + def is_success(self) -> bool: + return ( + self.vnp_ResponseCode == "00" + and self.vnp_TransactionStatus == "00" + ) + + +class IPNRequest(_VNPayCallbackBase): + """ + Query parameters received on the merchant's IPN URL. + + Use ``VNPayClient.verify_ipn()`` to validate and parse these. + """ + + +class ReturnURLRequest(_VNPayCallbackBase): + """ + Query parameters received on the merchant's ReturnURL. + + Use ``VNPayClient.verify_return_url()`` to validate and parse these. + """ + + +# --------------------------------------------------------------------------- +# Structured response objects returned by the library +# --------------------------------------------------------------------------- + + +class PaymentResponse(BaseModel): + """Returned by ``VNPayClient.create_payment_url()``.""" + + payment_url: str + txn_ref: str + amount: int + created_at: datetime + + +class IPNResponse(BaseModel): + """ + The JSON body the merchant must return to VNPAY after processing an IPN. + """ + + RspCode: str + Message: str diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index e1ca77b329..31661d55a1 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -148,6 +148,38 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } as const; +export const CreatePaymentRequestSchema = { + properties: { + amount: { + type: 'integer', + title: 'Amount' + } + }, + type: 'object', + required: ['amount'], + title: 'CreatePaymentRequest' +} as const; + +export const CreatePaymentResponseSchema = { + properties: { + payment_url: { + type: 'string', + title: 'Payment Url' + }, + txn_ref: { + type: 'string', + title: 'Txn Ref' + }, + amount: { + type: 'integer', + title: 'Amount' + } + }, + type: 'object', + required: ['payment_url', 'txn_ref', 'amount'], + title: 'CreatePaymentResponse' +} as const; + export const FilePublicSchema = { properties: { filename: { @@ -446,6 +478,41 @@ export const TokenSchema = { title: 'Token' } as const; +export const TopupPackageSchema = { + properties: { + id: { + type: 'string', + title: 'Id' + }, + amount: { + type: 'integer', + title: 'Amount' + }, + label: { + type: 'string', + title: 'Label' + } + }, + type: 'object', + required: ['id', 'amount', 'label'], + title: 'TopupPackage' +} as const; + +export const TopupPackagesResponseSchema = { + properties: { + packages: { + items: { + '$ref': '#/components/schemas/TopupPackage' + }, + type: 'array', + title: 'Packages' + } + }, + type: 'object', + required: ['packages'], + title: 'TopupPackagesResponse' +} as const; + export const UpdatePasswordSchema = { properties: { current_password: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index b582842846..c604b98ecd 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; +import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreateTopupPaymentData, TopupCreateTopupPaymentResponse, TopupTopupReturnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; export class ApiKeysService { /** @@ -444,6 +444,58 @@ export class StoragesService { } } +export class TopupService { + /** + * Get Topup Packages + * Return the list of available top-up packages. + * @returns TopupPackagesResponse Successful Response + * @throws ApiError + */ + public static getTopupPackages(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/topup/packages' + }); + } + + /** + * Create Topup Payment + * Generate a VNPAY payment URL for the selected top-up package. + * The client should redirect the user (or display a QR code) using + * the returned ``payment_url``. + * @param data The data for the request. + * @param data.requestBody + * @returns CreatePaymentResponse Successful Response + * @throws ApiError + */ + public static createTopupPayment(data: TopupCreateTopupPaymentData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/topup/create-payment', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Topup Return + * VNPAY ReturnURL handler – VNPAY redirects the customer's browser here + * after payment. In production you would verify the signature, update + * the user balance, and redirect to the front-end result page. + * @returns unknown Successful Response + * @throws ApiError + */ + public static topupReturn(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/topup/return' + }); + } +} + export class UsersService { /** * Read Users diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index cd07096033..a102b6d4b9 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -29,6 +29,16 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type CreatePaymentRequest = { + amount: number; +}; + +export type CreatePaymentResponse = { + payment_url: string; + txn_ref: string; + amount: number; +}; + export type FilePublic = { filename: string; content_type: string; @@ -90,6 +100,16 @@ export type Token = { token_type?: string; }; +export type TopupPackage = { + id: string; + amount: number; + label: string; +}; + +export type TopupPackagesResponse = { + packages: Array; +}; + export type UpdatePassword = { current_password: string; new_password: string; @@ -276,6 +296,16 @@ export type LoginRecoverPasswordHtmlContentResponse = (string); export type StoragesGetMyStorageStatResponse = (UserStorageStatPublic); +export type TopupGetTopupPackagesResponse = (TopupPackagesResponse); + +export type TopupCreateTopupPaymentData = { + requestBody: CreatePaymentRequest; +}; + +export type TopupCreateTopupPaymentResponse = (CreatePaymentResponse); + +export type TopupTopupReturnResponse = (unknown); + export type UsersReadUsersData = { limit?: number; skip?: number; diff --git a/frontend/src/components/Common/LanguageSwitcher.tsx b/frontend/src/components/Common/LanguageSwitcher.tsx index 657ddf7284..ff7b95bdcd 100644 --- a/frontend/src/components/Common/LanguageSwitcher.tsx +++ b/frontend/src/components/Common/LanguageSwitcher.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next" import { DropdownMenu, DropdownMenuContent, @@ -5,7 +6,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { SUPPORTED_LANGUAGES } from "@/i18n" -import { useTranslation } from "react-i18next" export const LanguageSwitcher = () => { const { i18n } = useTranslation() diff --git a/frontend/src/components/Files/FilePreviewModal.tsx b/frontend/src/components/Files/FilePreviewModal.tsx index 33704974f3..c8106a8e0e 100644 --- a/frontend/src/components/Files/FilePreviewModal.tsx +++ b/frontend/src/components/Files/FilePreviewModal.tsx @@ -1,12 +1,8 @@ -import { useState } from "react" import { ChevronDown, DownloadIcon, Eye, Loader2 } from "lucide-react" -import { FilesService, type FilePublic } from "@/client" +import { useState } from "react" +import { type FilePublic, FilesService } from "@/client" import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogTitle, -} from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, @@ -15,8 +11,13 @@ import { } from "@/components/ui/dropdown-menu" import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" -async function fetchPreviewJson(fileId: string): Promise[]> { - return FilesService.downloadTableExcelFile({ fileId, type: "json" }) as Promise[]> +async function fetchPreviewJson( + fileId: string, +): Promise[]> { + return FilesService.downloadTableExcelFile({ + fileId, + type: "json", + }) as Promise[]> } export function FilePreviewModal({ file }: { file: FilePublic }) { @@ -95,7 +96,9 @@ export function FilePreviewModal({ file }: { file: FilePublic }) { handleDownload("xlsx")}> Excel (.xlsx) - handleDownload("xlsx-acc-code")}> + handleDownload("xlsx-acc-code")} + > Analyze Account Code then Excel (.xlsx) handleDownload("csv")}> @@ -136,45 +139,54 @@ export function FilePreviewModal({ file }: { file: FilePublic }) {
    - {col} + {String(col ?? "")}
    - {String(row[col] ?? "—")} - + {display} +
    - {rows.length > 0 && Object.values(rows[0]).map((col) => ( - - ))} + {rows.length > 0 && + Object.values(rows[0]).map((col) => ( + + ))} - { - rows.length > 1 && rows.slice(1).map((row, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: rows have no stable id - - {cols.map((col) => { - const val = row[col] - let display: string - if (val == null || String(val).trim() === "") { - display = "—" - } else { - const str = String(val).trim() - // strip thousands separators then check if it's a pure number string - const stripped = str.replace(/,/g, "") - const num = Number(stripped) - if (!Number.isNaN(num) && /^-?\d+(\.\d+)?$/.test(stripped)) { - display = num.toLocaleString() + {rows.length > 1 && + rows.slice(1).map((row, i) => ( + + {cols.map((col) => { + const val = row[col] + let display: string + if (val == null || String(val).trim() === "") { + display = "—" } else { - display = str + const str = String(val).trim() + // strip thousands separators then check if it's a pure number string + const stripped = str.replace(/,/g, "") + const num = Number(stripped) + if ( + !Number.isNaN(num) && + /^-?\d+(\.\d+)?$/.test(stripped) + ) { + display = num.toLocaleString() + } else { + display = str + } } - } - return ( - - ) - })} - - ))} + return ( + + ) + })} + + ))}
    - {String(col ?? "")} - + {String(col ?? "")} +
    - {display} -
    + {display} +
    )} @@ -183,4 +195,4 @@ export function FilePreviewModal({ file }: { file: FilePublic }) {
    ) -} \ No newline at end of file +} diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index 60757d5868..61613770ad 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -9,10 +9,10 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" import { cn } from "@/lib/utils" import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" -import { type DownloadFormat, useDownloadFile } from "@/hooks/useDownloadFile" import { FilePreviewModal } from "./FilePreviewModal" function DownloadMenu({ file }: { file: FilePublic }) { diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index ae1c1f7d81..a03abe378e 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Files, Home, Users, Key } from "lucide-react" +import { Files, Home, Users, Key, Wallet } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -15,6 +15,7 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/dashboard" }, { icon: Files, title: "Files", path: "/files" }, + { icon: Wallet, title: "Top Up", path: "/topup" }, { icon: Key, title: "API Keys", path: "/api-keys" }, ] diff --git a/frontend/src/components/footer.tsx b/frontend/src/components/footer.tsx index 0ba6eb1196..21d5405f98 100644 --- a/frontend/src/components/footer.tsx +++ b/frontend/src/components/footer.tsx @@ -34,7 +34,9 @@ export default function Footer() {
    diff --git a/frontend/src/components/testimonials.tsx b/frontend/src/components/testimonials.tsx index 56232ab876..38cc0b73ed 100644 --- a/frontend/src/components/testimonials.tsx +++ b/frontend/src/components/testimonials.tsx @@ -47,7 +47,10 @@ export default function Testimonials() { >
    {Array.from({ length: testimonial.rating }).map((_, i) => ( - + ))} diff --git a/frontend/src/hooks/useDownloadFile.ts b/frontend/src/hooks/useDownloadFile.ts index e487d9c3c5..615950c533 100644 --- a/frontend/src/hooks/useDownloadFile.ts +++ b/frontend/src/hooks/useDownloadFile.ts @@ -1,7 +1,7 @@ import { useMutation } from "@tanstack/react-query" -import useCustomToast from "./useCustomToast" import type { ApiError } from "@/client" import { fetchBlobWithAuth } from "@/lib/fetchWithAuth" +import useCustomToast from "./useCustomToast" function triggerBlobDownload(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) @@ -30,15 +30,19 @@ export function useDownloadFile() { const safeName = filename.replace(/\.[^.]+$/, "") if (format === "xlsx-acc-code") { - const blob = await fetchBlobWithAuth(`/api/v1/files/${fileId}/download/new`) + const blob = await fetchBlobWithAuth( + `/api/v1/files/${fileId}/download/new`, + ) triggerBlobDownload(blob, `${safeName}_tables_with_acc_codes.xlsx`) } else { - const blob = await fetchBlobWithAuth(`/api/v1/files/${fileId}/download?type=${format}`) + const blob = await fetchBlobWithAuth( + `/api/v1/files/${fileId}/download?type=${format}`, + ) triggerBlobDownload(blob, `${safeName}_tables.${format}`) } }, onError: (err: ApiError) => { - console.log("Download error:", err) + console.log("Download error:", err) showErrorToast(err instanceof Error ? err.message : "Download failed") }, }) diff --git a/frontend/src/lib/fetchWithAuth.ts b/frontend/src/lib/fetchWithAuth.ts index 48df0ea928..866237ec06 100644 --- a/frontend/src/lib/fetchWithAuth.ts +++ b/frontend/src/lib/fetchWithAuth.ts @@ -1,6 +1,5 @@ import axios from "axios" import { OpenAPI } from "@/client/core/OpenAPI" -import { FilesService } from "@/client" async function getAuthHeaders(): Promise> { const token = diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 5e8d53bb3b..f8641b58ce 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ import { Route as PublicRouteImport } from './routes/_public' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as PublicIndexRouteImport } from './routes/_public/index' import { Route as PublicPricingRouteImport } from './routes/_public/pricing' +import { Route as LayoutTopupRouteImport } from './routes/_layout/topup' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutItemsRouteImport } from './routes/_layout/items' import { Route as LayoutFilesRouteImport } from './routes/_layout/files' @@ -62,6 +63,11 @@ const PublicPricingRoute = PublicPricingRouteImport.update({ path: '/pricing', getParentRoute: () => PublicRoute, } as any) +const LayoutTopupRoute = LayoutTopupRouteImport.update({ + id: '/topup', + path: '/topup', + getParentRoute: () => LayoutRoute, +} as any) const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -105,6 +111,7 @@ export interface FileRoutesByFullPath { '/files': typeof LayoutFilesRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute + '/topup': typeof LayoutTopupRoute '/pricing': typeof PublicPricingRoute } export interface FileRoutesByTo { @@ -119,6 +126,7 @@ export interface FileRoutesByTo { '/files': typeof LayoutFilesRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute + '/topup': typeof LayoutTopupRoute '/pricing': typeof PublicPricingRoute } export interface FileRoutesById { @@ -135,6 +143,7 @@ export interface FileRoutesById { '/_layout/files': typeof LayoutFilesRoute '/_layout/items': typeof LayoutItemsRoute '/_layout/settings': typeof LayoutSettingsRoute + '/_layout/topup': typeof LayoutTopupRoute '/_public/pricing': typeof PublicPricingRoute '/_public/': typeof PublicIndexRoute } @@ -152,6 +161,7 @@ export interface FileRouteTypes { | '/files' | '/items' | '/settings' + | '/topup' | '/pricing' fileRoutesByTo: FileRoutesByTo to: @@ -166,6 +176,7 @@ export interface FileRouteTypes { | '/files' | '/items' | '/settings' + | '/topup' | '/pricing' id: | '__root__' @@ -181,6 +192,7 @@ export interface FileRouteTypes { | '/_layout/files' | '/_layout/items' | '/_layout/settings' + | '/_layout/topup' | '/_public/pricing' | '/_public/' fileRoutesById: FileRoutesById @@ -252,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PublicPricingRouteImport parentRoute: typeof PublicRoute } + '/_layout/topup': { + id: '/_layout/topup' + path: '/topup' + fullPath: '/topup' + preLoaderRoute: typeof LayoutTopupRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/settings': { id: '/_layout/settings' path: '/settings' @@ -304,6 +323,7 @@ interface LayoutRouteChildren { LayoutFilesRoute: typeof LayoutFilesRoute LayoutItemsRoute: typeof LayoutItemsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute + LayoutTopupRoute: typeof LayoutTopupRoute } const LayoutRouteChildren: LayoutRouteChildren = { @@ -313,6 +333,7 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutFilesRoute: LayoutFilesRoute, LayoutItemsRoute: LayoutItemsRoute, LayoutSettingsRoute: LayoutSettingsRoute, + LayoutTopupRoute: LayoutTopupRoute, } const LayoutRouteWithChildren = diff --git a/frontend/src/routes/_layout/api-keys.tsx b/frontend/src/routes/_layout/api-keys.tsx index 13e938577b..43d6017ff0 100644 --- a/frontend/src/routes/_layout/api-keys.tsx +++ b/frontend/src/routes/_layout/api-keys.tsx @@ -199,9 +199,7 @@ function ApiKeysPage() { {k.name || "(unnamed)"} - {k.created_at - ? new Date(k.created_at).toLocaleString() - : "—"} + {k.created_at ? new Date(k.created_at).toLocaleString() : "—"} + ) + })} +
    + ) +} + +function PackageGridSkeleton() { + return ( +
    + {Array.from({ length: 9 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton list + + ))} +
    + ) +} + +function QRCodeDisplay({ url }: { url: string }) { + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(url)}` + return ( +
    + ) +} + +function TopupContent() { + const [selected, setSelected] = useState(null) + const [paymentUrl, setPaymentUrl] = useState(null) + const { showSuccessToast } = useCustomToast() + + const mutation = useMutation({ + mutationFn: (amount: number) => + TopupService.createTopupPayment({ requestBody: { amount } }), + onSuccess: (data) => { + setPaymentUrl(data.payment_url) + showSuccessToast("QR code generated! Scan to pay.") + }, + onError: handleError, + }) + + const handleGenerate = () => { + if (!selected) return + setPaymentUrl(null) + mutation.mutate(selected.amount) + } + + const handleSelectPackage = (pkg: TopupPackage) => { + setSelected(pkg) + setPaymentUrl(null) + } + + return ( +
    + {/* Package selection */} + + + + + Select a top-up package + + + + }> + + + + {selected && ( +
    + Selected + + {formatVND(selected.amount)} + +
    + )} + + +
    +
    + + {/* QR Code result */} + {paymentUrl && ( + + + Scan to pay + + + + + + )} +
    + ) +} + +function TopupPage() { + return ( +
    +
    +

    Top Up

    +

    + Add balance to your account via VNPAY +

    +
    + +
    + ) +} From 0549883fa02ef81559407ad93336452149061a79 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sat, 25 Apr 2026 01:36:10 +0700 Subject: [PATCH 20/30] update filejob --- .../d1e2f3a4b5c6_create_file_jobs_table.py | 42 +++++ ...e2f3a4b5c6d7_drop_job_fields_from_files.py | 42 +++++ backend/app/aws/client.py | 1 - backend/app/files/crud.py | 71 ++++++-- backend/app/files/models.py | 22 ++- backend/app/files/router.py | 116 +++++++++--- backend/app/files/schemas.py | 35 +++- backend/app/files/service.py | 19 +- backend/app/files/utils.py | 32 ++-- backend/app/models.py | 2 +- backend/app/ocrs/service.py | 102 +++++++---- frontend/src/client/schemas.gen.ts | 168 +++++++++++++++--- frontend/src/client/sdk.gen.ts | 60 ++++++- frontend/src/client/types.gen.ts | 53 ++++-- .../src/components/Files/FilePreviewModal.tsx | 4 +- frontend/src/components/Files/columns.tsx | 20 ++- .../src/components/Sidebar/AppSidebar.tsx | 2 +- frontend/src/routes/_layout/files.tsx | 31 ++-- frontend/src/routes/_layout/topup.tsx | 2 +- 19 files changed, 648 insertions(+), 176 deletions(-) create mode 100644 backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py create mode 100644 backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py diff --git a/backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py b/backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py new file mode 100644 index 0000000000..473723bf3c --- /dev/null +++ b/backend/app/alembic/versions/d1e2f3a4b5c6_create_file_jobs_table.py @@ -0,0 +1,42 @@ +"""create_file_jobs_table + +Revision ID: d1e2f3a4b5c6 +Revises: a24416477b07 +Create Date: 2026-04-25 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'd1e2f3a4b5c6' +down_revision = 'a24416477b07' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'file_jobs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('job_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('file_id', sa.Uuid(), nullable=False), + sa.Column('state', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('total_pages', sa.Integer(), nullable=True), + sa.Column('extracted_pages', sa.Integer(), nullable=True), + sa.Column('start_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('json_url', sqlmodel.sql.sqltypes.AutoString(length=4000), nullable=True), + sa.Column('markdown_url', sqlmodel.sql.sqltypes.AutoString(length=4000), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['file_id'], ['files.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_file_jobs_job_id'), 'file_jobs', ['job_id'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_file_jobs_job_id'), table_name='file_jobs') + op.drop_table('file_jobs') diff --git a/backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py b/backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py new file mode 100644 index 0000000000..3ae981aadf --- /dev/null +++ b/backend/app/alembic/versions/e2f3a4b5c6d7_drop_job_fields_from_files.py @@ -0,0 +1,42 @@ +"""drop_job_fields_from_files_add_err_msg_to_file_jobs + +Revision ID: e2f3a4b5c6d7 +Revises: d1e2f3a4b5c6 +Create Date: 2026-04-25 00:01:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'e2f3a4b5c6d7' +down_revision = 'd1e2f3a4b5c6' +branch_labels = None +depends_on = None + + +def upgrade(): + # Drop job-tracking columns from files table + op.drop_index('ix_files_job_id', table_name='files') + op.drop_column('files', 'job_id') + op.drop_column('files', 'job_status') + op.drop_column('files', 'err_msg') + + # Add err_msg to file_jobs table + op.add_column( + 'file_jobs', + sa.Column('err_msg', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + ) + + +def downgrade(): + # Restore columns on files table + op.add_column('files', sa.Column('err_msg', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.add_column('files', sa.Column('job_status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True)) + op.add_column('files', sa.Column('job_id', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True)) + op.create_index('ix_files_job_id', 'files', ['job_id'], unique=False) + + # Drop err_msg from file_jobs + op.drop_column('file_jobs', 'err_msg') diff --git a/backend/app/aws/client.py b/backend/app/aws/client.py index f926332ae7..36f4842eb5 100644 --- a/backend/app/aws/client.py +++ b/backend/app/aws/client.py @@ -59,5 +59,4 @@ def download_file_from_r2(key: str, bucket: str | None = None) -> bytes: client = get_s3_client() response = client.get_object(Bucket=bucket, Key=key) - print('S3 get_object response metadata:', response.get("ResponseMetadata", {})) return response["Body"].read() \ No newline at end of file diff --git a/backend/app/files/crud.py b/backend/app/files/crud.py index f26dd60beb..60841367d9 100644 --- a/backend/app/files/crud.py +++ b/backend/app/files/crud.py @@ -1,9 +1,9 @@ import uuid -from sqlmodel import Session +from sqlmodel import Session, select -from app.files.models import File -from app.files.schemas import FileCreate +from app.files.models import File, FileJob +from app.files.schemas import FileCreate, FileJobCreate def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> File: @@ -13,24 +13,59 @@ def create_file(*, session: Session, file_in: FileCreate, user_id: uuid.UUID) -> session.refresh(db_file) return db_file + def delete_file(*, session: Session, file_id: uuid.UUID) -> None: db_file = session.get(File, file_id) if db_file: session.delete(db_file) session.commit() -def update_file_info( - session: Session, file_id: uuid.UUID, job_status: str, job_id: str | None = None, err_msg : str | None = None -) -> File | None: - db_file: File | None = session.get(File, file_id) - if db_file: - db_file.job_status = job_status - if job_id: - db_file.job_id = job_id - if err_msg: - db_file.err_msg = err_msg - session.add(db_file) - session.commit() - session.refresh(db_file) - return db_file - return None \ No newline at end of file + +# --------------------------------------------------------------------------- +# FileJob CRUD +# --------------------------------------------------------------------------- + +def create_file_job(*, session: Session, file_job_in: FileJobCreate) -> FileJob: + db_file_job = FileJob.model_validate(file_job_in) + session.add(db_file_job) + session.commit() + session.refresh(db_file_job) + return db_file_job + + +def get_file_job_by_file_id(*, session: Session, file_id: uuid.UUID) -> FileJob | None: + statement = select(FileJob).where(FileJob.file_id == file_id) + return session.exec(statement).first() + + +def get_file_job_by_job_id(*, session: Session, job_id: str) -> FileJob | None: + statement = select(FileJob).where(FileJob.job_id == job_id) + return session.exec(statement).first() + + +def update_file_job( + *, + session: Session, + file_job: FileJob, + state: str, + total_pages: int | None = None, + extracted_pages: int | None = None, + json_url: str | None = None, + markdown_url: str | None = None, + err_msg: str | None = None, +) -> FileJob: + file_job.state = state + if total_pages is not None: + file_job.total_pages = total_pages + if extracted_pages is not None: + file_job.extracted_pages = extracted_pages + if json_url is not None: + file_job.json_url = json_url + if markdown_url is not None: + file_job.markdown_url = markdown_url + if err_msg is not None: + file_job.err_msg = err_msg + session.add(file_job) + session.commit() + session.refresh(file_job) + return file_job diff --git a/backend/app/files/models.py b/backend/app/files/models.py index 10cd0c6d63..e6a5089af4 100644 --- a/backend/app/files/models.py +++ b/backend/app/files/models.py @@ -3,7 +3,6 @@ import uuid from datetime import datetime -from alembic.util import err from sqlalchemy import DateTime from sqlmodel import Field, SQLModel @@ -18,9 +17,6 @@ class File(SQLModel, table=True): content_type: str = Field(min_length=1, max_length=255) size: int url: str | None = None - job_id: str | None = Field(default=None, max_length=255, index=True) - job_status: str | None = Field(default=OcrJobStatus.PENDING, max_length=50) - err_msg: str | None = Field(default=None, max_length=255) bank: str | None = Field(default=None, max_length=255) created_at: datetime = Field( default_factory=get_datetime_utc, @@ -29,3 +25,21 @@ class File(SQLModel, table=True): user_id: uuid.UUID = Field( foreign_key="users.id", nullable=False, ondelete="CASCADE" ) + +class FileJob(SQLModel, table=True): + __tablename__ = "file_jobs" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + job_id: str = Field(max_length=255, index=True) + file_id: uuid.UUID = Field(foreign_key="files.id", nullable=False, ondelete="CASCADE") + state: str = Field(default=OcrJobStatus.PENDING, max_length=50) + total_pages: int | None = None + extracted_pages: int | None = None + start_time: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) # ty:ignore[invalid-argument-type] + end_time: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) # ty:ignore[invalid-argument-type] + json_url: str | None = Field(default=None, max_length=4000) + markdown_url: str | None = Field(default=None, max_length=4000) + err_msg: str | None = Field(default=None, max_length=500) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # ty:ignore[invalid-argument-type] + ) diff --git a/backend/app/files/router.py b/backend/app/files/router.py index 67b6b1f9c5..ac24823642 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -6,15 +6,26 @@ from app.aws.client import upload_file_to_r2 from app.backend_pre_start import logger -from app.files.crud import create_file, delete_file, update_file_info +from app.files.crud import ( + create_file, + delete_file, + get_file_job_by_file_id, + update_file_job, +) from app.files.dependencies import CurrentUser, SessionDep -from app.files.models import File -from app.files.schemas import FileCreate, FilePublic, FilesPublic, FilesStatusRequest +from app.files.models import File, FileJob +from app.files.schemas import ( + FileCreate, + FileJobPublic, + FilePublic, + FilesStatusRequest, + FileWithJobPublic, +) from app.files.service import ( download_file, download_file_with_account_code, ) -from app.ocrs.service import get_ocr_job_status, post_ocr_jobs +from app.ocrs.service import get_ocr_job_status, get_ocr_job_status_1, post_ocr_jobs router = APIRouter(prefix="/files", tags=["files"]) @@ -57,7 +68,7 @@ def upload_file_endpoint( logger.error(f"Error handling uploaded file {file_name}: {exc}") raise HTTPException(status_code=500, detail=str(exc)) -@router.put("/{file_id}") +@router.put("/{file_id}", response_model=FileJobPublic) def update_file_job_status_endpoint( file_id: uuid.UUID, job_status: str, @@ -66,29 +77,49 @@ def update_file_job_status_endpoint( """ Update the job status for a file based on OCR job updates. """ + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job: + raise HTTPException(status_code=404, detail="FileJob not found") + updated = update_file_job(session=session, file_job=file_job, state=job_status) + return updated - updated_file = update_file_info(session=session, file_id=file_id, job_status=job_status) - if not updated_file: +@router.get("/{file_id}/status", response_model=FileJobPublic) +def get_file_status(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Get the current OCR job status for a file by polling the OCR API. + """ + file = session.get(File, file_id) + if not file: raise HTTPException(status_code=404, detail="File not found") - return {"message": "Job status updated", "file_id": str(updated_file.id), "job_status": updated_file.job_status} -@router.get("/{file_id}/status", response_model=FilePublic) -def get_file_status(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + get_ocr_job_status(file=file, session=session, user=user) # Poll & persist latest state + + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job: + raise HTTPException(status_code=404, detail="No job found for this file") + return file_job + +@router.get("/{file_id}/job", response_model=FileJobPublic) +def get_file_job(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): """ - Get the current status of a file, including OCR job status if applicable. + Get the FileJob record for a given file, containing detailed OCR progress info. """ file = session.get(File, file_id) if not file: raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") - file.job_status = get_ocr_job_status(file=file, session=session, user=user) # Poll OCR API for latest status + file_job: FileJob | None = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job: + raise HTTPException(status_code=404, detail="No job found for this file") + return file_job - return file -@router.get('/', response_model=FilesPublic) +@router.get('/', response_model=list[FileWithJobPublic]) def list_files(session: SessionDep, user: CurrentUser, skip: int = 0, limit: int = 0): """ - List all files uploaded by the current user. + List all files uploaded by the current user, each enriched with its FileJob. """ user_id = user.id if limit <= 0: @@ -98,7 +129,13 @@ def list_files(session: SessionDep, user: CurrentUser, skip: int = 0, limit: int files = session.exec(statement).all() - return FilesPublic(data=files, count=len(files)) # ty:ignore[invalid-argument-type] + result: list[FileWithJobPublic] = [] + for f in files: + file_job = get_file_job_by_file_id(session=session, file_id=f.id) + job_public: FileJobPublic | None = FileJobPublic.model_validate(file_job) if file_job else None + result.append(FileWithJobPublic.model_validate(f, update={"job": job_public})) + + return result @router.post("/{file_id}/download", response_class=Response) def download_table_excel_file(file_id: uuid.UUID, type: str, session: SessionDep, user: CurrentUser,): @@ -108,19 +145,24 @@ def download_table_excel_file(file_id: uuid.UUID, type: str, session: SessionDep file = session.get(File, file_id) if not file: raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: raise HTTPException(status_code=403, detail="Not authorized to access this file") - if file.job_status != "done": + + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + + if not file_job or file_job.state != "done": raise HTTPException(status_code=400, detail="OCR job is not done yet") + logger.info(f"Preparing to stream file {file_id} for user {user.email} with requested type {type}") - excel_bytes, content_disposition = download_file(file=file, user=user, type=type) + excel_bytes, content_disposition = download_file(session=session, file=file, user=user, type=type) media_type = { "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "csv": "text/csv", "json": "application/json", "html": "text/html", }.get(type, "application/octet-stream") - logger.info(f"Streaming file {file_id} to user {user.email} with media type {media_type}") + return Response( content=excel_bytes, media_type=media_type, @@ -141,7 +183,8 @@ def download_new_version_excel(file_id: uuid.UUID, session: SessionDep, user: Cu raise HTTPException(status_code=404, detail="File not found") if file.user_id != user.id: raise HTTPException(status_code=403, detail="Not authorized to access this file") - if file.job_status != "done": + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job or file_job.state != "done": raise HTTPException(status_code=400, detail="OCR job is not done yet") try: ex_bytes, content_disposition = download_file_with_account_code(session=session, file=file, user=user) @@ -154,18 +197,18 @@ def download_new_version_excel(file_id: uuid.UUID, session: SessionDep, user: Cu headers={"Content-Disposition": content_disposition}, ) -@router.post("/batch/status", response_model=FilesPublic) +@router.post("/batch/status", response_model=list[FileJobPublic]) def get_files_batch_status( body: FilesStatusRequest, session: SessionDep, user: CurrentUser, ): """ - Accept a list of file IDs, refresh each file's OCR job status, - and return the updated list of files. + Accept a list of file IDs, refresh each file's OCR job status via the OCR API, + and return the updated list of FileJob records. """ logger.info(f"Received batch status request for file IDs: {body.file_ids} from user {user.email}") - files: list[File] = [] + file_jobs: list[FileJob] = [] for file_id in body.file_ids: file = session.get(File, file_id) logger.info(f"Processing file ID {file_id}: found file {file} in database") @@ -175,10 +218,29 @@ def get_files_batch_status( raise HTTPException(status_code=403, detail=f"Not authorized to access file {file_id}") try: - file.job_status = get_ocr_job_status(file=file, session=session, user=user) + get_ocr_job_status(file=file, session=session, user=user) except Exception as exc: logger.error(f"Error refreshing OCR status for file {file_id}: {exc}") - files.append(file) + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if file_job: + file_jobs.append(file_job) + + return file_jobs + +@router.get("/{file_id}/result_url") +def get_file_result_url(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): + """ + Get the presigned URL for the OCR result JSON file in R2 for a given file ID. + """ + file = session.get(File, file_id) + if not file: + raise HTTPException(status_code=404, detail="File not found") + if file.user_id != user.id: + raise HTTPException(status_code=403, detail="Not authorized to access this file") + file_job = get_file_job_by_file_id(session=session, file_id=file_id) + if not file_job or file_job.state != "done": + raise HTTPException(status_code=400, detail="OCR job is not done yet") - return FilesPublic(data=[FilePublic.model_validate(f) for f in files], count=len(files)) + result = get_ocr_job_status_1(file=file, session=session, user=user) + return {"result": result} diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py index aa6ca460a6..53bf929cf7 100644 --- a/backend/app/files/schemas.py +++ b/backend/app/files/schemas.py @@ -8,8 +8,6 @@ class FileBase(SQLModel): filename: str = Field(min_length=1, max_length=255) content_type: str = Field(min_length=1, max_length=255) size: int | None = None - job_id: str | None = Field(default=None, max_length=255) - job_status: str | None = Field(default=None, max_length=50) class FileCreate(FileBase): url: str | None = None @@ -26,3 +24,36 @@ class FilesPublic(SQLModel): class FilesStatusRequest(SQLModel): file_ids: list[uuid.UUID] + + +# --------------------------------------------------------------------------- +# FileJob schemas +# --------------------------------------------------------------------------- + +class FileJobCreate(SQLModel): + job_id: str = Field(max_length=255) + file_id: uuid.UUID + state: str = Field(max_length=50) + total_pages: int | None = None + extracted_pages: int | None = None + json_url: str | None = Field(default=None, max_length=4000) + markdown_url: str | None = Field(default=None, max_length=4000) + err_msg: str | None = Field(default=None, max_length=500) + + +class FileJobPublic(SQLModel): + id: uuid.UUID + job_id: str + file_id: uuid.UUID + state: str + total_pages: int | None = None + extracted_pages: int | None = None + json_url: str | None = None + markdown_url: str | None = None + err_msg: str | None = None + created_at: datetime | None = None + + +class FileWithJobPublic(FilePublic): + """FilePublic enriched with its associated FileJob (if any).""" + job: FileJobPublic | None = None diff --git a/backend/app/files/service.py b/backend/app/files/service.py index dce75bad56..6054b9b280 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -7,13 +7,13 @@ from sqlmodel import Session from app.api_keys.crud import get_api_key_by_user -from app.aws.client import download_file_from_r2, generate_presigned_put_url +from app.aws.client import generate_presigned_put_url from app.aws.config import aws_settings -from app.core.config import settings +from app.files.crud import get_file_job_by_file_id from app.files.dependencies import CurrentUser from app.files.models import File from app.files.strategies import DOWNLOAD_STRATEGIES -from app.files.utils import get_df_from_json_bytes, get_df_from_result_json +from app.files.utils import get_df_from_result_json user_instruction = ( "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung để xác định giao dịch (Bạn phải tự xác định cột chứa nội dung giao dịch)" @@ -23,7 +23,7 @@ "không thêm giải thích, chú thích hay văn bản khác. Chỉ output nội dung file mới.\n\n" ) -def download_file(file: File, user: CurrentUser, type: str = "xlsx") -> tuple[bytes, str]: +def download_file(session: Session, file: File, user: CurrentUser, type: str = "xlsx") -> tuple[bytes, str]: """ Given a File record, download the file content from its URL and return bytes and a Content-Disposition header for the requested format. @@ -37,12 +37,11 @@ def download_file(file: File, user: CurrentUser, type: str = "xlsx") -> tuple[by if strategy is None: raise ValueError(f"Unsupported file type requested: {type}") - json_key = f"{user.email}/{file.id}/result.json" + file_job = get_file_job_by_file_id(session=session, file_id=file.id) + if not file_job or not file_job.json_url: + raise ValueError("No OCR result available for this file yet.") - # presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) - # df: DataFrame = get_df_from_result_json(presigned_url) - json_file = download_file_from_r2(key=json_key, bucket=aws_settings.R2_BUCKET_NAME) - df = get_df_from_json_bytes(json_file) + df: DataFrame = get_df_from_result_json(file_job.json_url) # type: ignore[union-attr] safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename data_bytes, content_disposition = strategy.convert(df, safe_name) @@ -68,7 +67,7 @@ def get_gemini_response_for_file(input_path: str, output_path: str, *, model: st Note: The GEMINI_API_KEY must be set in the environment for `genai.Client()` to authenticate. """ - client = genai.Client(api_key=settings.GMN_API_KEY) + client = genai.Client() if model is None: model = "gemini-3-flash-preview" diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py index 9711ced3bf..e2bfed0b5c 100644 --- a/backend/app/files/utils.py +++ b/backend/app/files/utils.py @@ -27,26 +27,36 @@ def extract_tables_from_ocr(data) -> pd.DataFrame: pass if not all_dfs: raise Exception("No tables found") - - # Merge all tables - for i in range(1, len(all_dfs)): - all_dfs[i].drop(0) merged = pd.concat(all_dfs, ignore_index=True) return merged +def _extract_tables_from_ndjson(text: str) -> DataFrame: + """Parse NDJSON (one JSON object per line) and extract all tables.""" + all_dfs: list[DataFrame] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + data = json.loads(line) + try: + df = extract_tables_from_ocr(data) + all_dfs.append(df) + except Exception: + pass + if not all_dfs: + raise Exception("No tables found") + return pd.concat(all_dfs, ignore_index=True) + + def get_df_from_result_json(url) -> DataFrame: res = requests.get(url) res.raise_for_status() + return _extract_tables_from_ndjson(res.text) - data = res.json() - - return extract_tables_from_ocr(data) def get_df_from_json_bytes(json_bytes: bytes) -> DataFrame: - - json_data = json.loads(json_bytes.decode("utf-8")) - - return extract_tables_from_ocr(json_data) \ No newline at end of file + text = json_bytes.decode("utf-8") + return _extract_tables_from_ndjson(text) diff --git a/backend/app/models.py b/backend/app/models.py index 83e089792e..a565acf003 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -11,7 +11,7 @@ from app.api_keys.models import ApiKey # noqa: F401 from app.auth.schemas import NewPassword, Token, TokenPayload # noqa: F401 -from app.files.models import File # noqa: F401 +from app.files.models import File, FileJob # noqa: F401 from app.files.schemas import ( # noqa: F401 FileBase, FileCreate, diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 595682fea0..7765ef03e5 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -5,8 +5,13 @@ from app.aws.client import upload_file_to_r2 from app.core.config import settings -from app.files.crud import update_file_info -from app.files.models import File +from app.files.crud import ( + create_file_job, + get_file_job_by_file_id, + update_file_job, +) +from app.files.models import File, FileJob +from app.files.schemas import FileJobCreate from app.ocrs.constants import OcrJobStatus from app.ocrs.dependencies import CurrentUser, SessionDep from app.ocrs.schemas import OcrJobResponse, OcrSubmitResponse @@ -28,8 +33,8 @@ def post_ocr_jobs(session: Session, file: File, file_url: str) -> tuple[bool, str | None]: """ - Submit an OCR job for the given file URL and update job_id / job_status - on the File record. Only posts the job — polling is handled separately. + Submit an OCR job for the given file URL and create a FileJob record. + Only posts the job — polling is handled separately. """ payload = { @@ -44,62 +49,99 @@ def post_ocr_jobs(session: Session, file: File, file_url: str) -> tuple[bool, st submit_response = OcrSubmitResponse.model_validate(raw.json()) is_success = submit_response.is_success() if not is_success: + create_file_job( + session=session, + file_job_in=FileJobCreate( + file_id=file.id, + state=OcrJobStatus.FAILED, + err_msg=submit_response.msg, + ), + ) logger.error("Failed to submit OCR job for file %s: %s - %s", file.id, submit_response.code, submit_response.msg) return (False, None) job_id = submit_response.data.jobId logger.info("OCR job submitted successfully for file %s, job_id: %s", file.id, job_id) - update_file_info( - session, - file_id=file.id, - job_status=OcrJobStatus.RUNNING, - job_id=job_id, - err_msg=None + + # Create a FileJob record to track this job + create_file_job( + session=session, + file_job_in=FileJobCreate( + job_id=job_id, + file_id=file.id, + state=OcrJobStatus.RUNNING, + ), ) return (is_success, job_id) + def get_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> str | None: """ - Poll the OCR API for job results. Returns a typed OcrJobResponse. + Poll the OCR API for job results. Reads job_id/state from the FileJob record. """ - if not file.job_id: - logger.error("File %s has no job_id but is being polled for OCR status", file.id) - update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg="No job_id for this file") - raise Exception("No job_id for this file") + file_job: FileJob | None = get_file_job_by_file_id(session=session, file_id=file.id) - if file.job_status == OcrJobStatus.DONE or file.job_status == OcrJobStatus.FAILED: - return file.job_status + if not file_job: + logger.error("File %s has no FileJob record", file.id) + raise Exception("No FileJob record for this file") - headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} - - raw = requests.get(f"{settings.OCR_JOB_URL}/{file.job_id}", headers=headers) + if file_job.state in (OcrJobStatus.DONE, OcrJobStatus.FAILED): + return file_job.state + req_headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} + raw = requests.get(f"{settings.OCR_JOB_URL}/{file_job.job_id}", headers=req_headers) assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" result: OcrJobResponse = OcrJobResponse.model_validate(raw.json()) - logger.info("get_ocr_job_status for file %s,\n result: %s", file.json(), result.json()) if not result.is_success(): - logger.error("Error fetching OCR job status for job_id %s: %s", file.job_id, result.msg) - update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.msg) + logger.error("Error fetching OCR job status for job_id %s: %s", file_job.job_id, result.msg) + update_file_job(session=session, file_job=file_job, state=OcrJobStatus.FAILED, err_msg=result.msg) raise Exception(f"OCR API error: {result.msg}") state = result.data.state - logger.info("OCR job %s status: %s", file.job_id, state) - if state == OcrJobStatus.RUNNING and file.job_status == OcrJobStatus.PENDING: - update_file_info(session, file_id=file.id, job_status=OcrJobStatus.RUNNING) # Update all files with this job_id + + if state == OcrJobStatus.RUNNING and file_job.state == OcrJobStatus.PENDING: + update_file_job(session=session, file_job=file_job, state=OcrJobStatus.RUNNING) elif state == OcrJobStatus.DONE: - logger.info("OCR job %s completed successfully. Result uploaded to R2.", file.job_id) - update_file_info(session, file_id=file.id, job_status=OcrJobStatus.DONE) + logger.info("OCR job %s completed successfully.", file_job.job_id) + extract = result.data.extractProgress + result_url = result.data.resultUrl + update_file_job( + session=session, + file_job=file_job, + state=OcrJobStatus.DONE, + total_pages=extract.totalPages if extract else None, + extracted_pages=extract.extractedPages if extract else None, + json_url=result_url.jsonUrl if result_url else None, + markdown_url=result_url.markdownUrl if result_url else None, + ) upload_ocr_job_result(user=user, file=file, result=result, session=session) elif state == OcrJobStatus.FAILED: - logger.error("OCR job %s failed: %s", file.job_id, result.data.errorMsg) - update_file_info(session, file_id=file.id, job_status=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) + logger.error("OCR job %s failed: %s", file_job.job_id, result.data.errorMsg) + update_file_job(session=session, file_job=file_job, state=OcrJobStatus.FAILED, err_msg=result.data.errorMsg) return state + +def get_ocr_job_status_1(file: File, session: SessionDep, user: CurrentUser) -> OcrJobResponse | None: + """ + Poll the OCR API for job results. Returns a typed OcrJobResponse. + """ + file_job: FileJob | None = get_file_job_by_file_id(session=session, file_id=file.id) + if not file_job: + logger.error("File %s has no FileJob record", file.id) + raise Exception("No FileJob record for this file") + + req_headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} + raw = requests.get(f"{settings.OCR_JOB_URL}/{file_job.job_id}", headers=req_headers) + assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" + + return OcrJobResponse.model_validate(raw.json()) + + def upload_ocr_job_result(user: CurrentUser, file: File, result: OcrJobResponse, session: SessionDep): key = f"{user.email}/{file.id}/result.json" (json_url, md_url) = (result.data.resultUrl.jsonUrl, result.data.resultUrl.markdownUrl) if result.data.resultUrl else (None, None) diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 31661d55a1..9c86a6d8a8 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -180,6 +180,99 @@ export const CreatePaymentResponseSchema = { title: 'CreatePaymentResponse' } as const; +export const FileJobPublicSchema = { + properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + job_id: { + type: 'string', + title: 'Job Id' + }, + file_id: { + type: 'string', + format: 'uuid', + title: 'File Id' + }, + state: { + type: 'string', + title: 'State' + }, + total_pages: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Total Pages' + }, + extracted_pages: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Extracted Pages' + }, + json_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Json Url' + }, + markdown_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Markdown Url' + }, + err_msg: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Err Msg' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['id', 'job_id', 'file_id', 'state'], + title: 'FileJobPublic' +} as const; + export const FilePublicSchema = { properties: { filename: { @@ -205,29 +298,58 @@ export const FilePublicSchema = { ], title: 'Size' }, - job_id: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { anyOf: [ { type: 'string', - maxLength: 255 + format: 'date-time' }, { type: 'null' } ], - title: 'Job Id' + title: 'Created At' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + } + }, + type: 'object', + required: ['filename', 'content_type', 'id', 'user_id'], + title: 'FilePublic' +} as const; + +export const FileWithJobPublicSchema = { + properties: { + filename: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Filename' }, - job_status: { + content_type: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Content Type' + }, + size: { anyOf: [ { - type: 'string', - maxLength: 50 + type: 'integer' }, { type: 'null' } ], - title: 'Job Status' + title: 'Size' }, id: { type: 'string', @@ -250,30 +372,22 @@ export const FilePublicSchema = { type: 'string', format: 'uuid', title: 'User Id' - } - }, - type: 'object', - required: ['filename', 'content_type', 'id', 'user_id'], - title: 'FilePublic' -} as const; - -export const FilesPublicSchema = { - properties: { - data: { - items: { - '$ref': '#/components/schemas/FilePublic' - }, - type: 'array', - title: 'Data' }, - count: { - type: 'integer', - title: 'Count' + job: { + anyOf: [ + { + '$ref': '#/components/schemas/FileJobPublic' + }, + { + type: 'null' + } + ] } }, type: 'object', - required: ['data', 'count'], - title: 'FilesPublic' + required: ['filename', 'content_type', 'id', 'user_id'], + title: 'FileWithJobPublic', + description: 'FilePublic enriched with its associated FileJob (if any).' } as const; export const FilesStatusRequestSchema = { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index c604b98ecd..243758c29c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreateTopupPaymentData, TopupCreateTopupPaymentResponse, TopupTopupReturnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; +import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesGetFileJobData, FilesGetFileJobResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, FilesGetFileResultUrlData, FilesGetFileResultUrlResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreateTopupPaymentData, TopupCreateTopupPaymentResponse, TopupTopupReturnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; export class ApiKeysService { /** @@ -83,11 +83,11 @@ export class FilesService { /** * List Files - * List all files uploaded by the current user. + * List all files uploaded by the current user, each enriched with its FileJob. * @param data The data for the request. * @param data.skip * @param data.limit - * @returns FilesPublic Successful Response + * @returns FileWithJobPublic Successful Response * @throws ApiError */ public static listFiles(data: FilesListFilesData = {}): CancelablePromise { @@ -110,7 +110,7 @@ export class FilesService { * @param data The data for the request. * @param data.fileId * @param data.jobStatus - * @returns unknown Successful Response + * @returns FileJobPublic Successful Response * @throws ApiError */ public static updateFileJobStatusEndpoint(data: FilesUpdateFileJobStatusEndpointData): CancelablePromise { @@ -131,10 +131,10 @@ export class FilesService { /** * Get File Status - * Get the current status of a file, including OCR job status if applicable. + * Get the current OCR job status for a file by polling the OCR API. * @param data The data for the request. * @param data.fileId - * @returns FilePublic Successful Response + * @returns FileJobPublic Successful Response * @throws ApiError */ public static getFileStatus(data: FilesGetFileStatusData): CancelablePromise { @@ -150,6 +150,27 @@ export class FilesService { }); } + /** + * Get File Job + * Get the FileJob record for a given file, containing detailed OCR progress info. + * @param data The data for the request. + * @param data.fileId + * @returns FileJobPublic Successful Response + * @throws ApiError + */ + public static getFileJob(data: FilesGetFileJobData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/files/{file_id}/job', + path: { + file_id: data.fileId + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Download Table Excel File * Stream an Excel file built from the OCR result JSON stored in R2. @@ -201,11 +222,11 @@ export class FilesService { /** * Get Files Batch Status - * Accept a list of file IDs, refresh each file's OCR job status, - * and return the updated list of files. + * Accept a list of file IDs, refresh each file's OCR job status via the OCR API, + * and return the updated list of FileJob records. * @param data The data for the request. * @param data.requestBody - * @returns FilesPublic Successful Response + * @returns FileJobPublic Successful Response * @throws ApiError */ public static getFilesBatchStatus(data: FilesGetFilesBatchStatusData): CancelablePromise { @@ -219,6 +240,27 @@ export class FilesService { } }); } + + /** + * Get File Result Url + * Get the presigned URL for the OCR result JSON file in R2 for a given file ID. + * @param data The data for the request. + * @param data.fileId + * @returns unknown Successful Response + * @throws ApiError + */ + public static getFileResultUrl(data: FilesGetFileResultUrlData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/files/{file_id}/result_url', + path: { + file_id: data.fileId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class ItemsService { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index a102b6d4b9..18cb5a16c9 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -39,26 +39,45 @@ export type CreatePaymentResponse = { amount: number; }; +export type FileJobPublic = { + id: string; + job_id: string; + file_id: string; + state: string; + total_pages?: (number | null); + extracted_pages?: (number | null); + json_url?: (string | null); + markdown_url?: (string | null); + err_msg?: (string | null); + created_at?: (string | null); +}; + export type FilePublic = { filename: string; content_type: string; size?: (number | null); - job_id?: (string | null); - job_status?: (string | null); id: string; created_at?: (string | null); user_id: string; }; -export type FilesPublic = { - data: Array; - count: number; -}; - export type FilesStatusRequest = { file_ids: Array<(string)>; }; +/** + * FilePublic enriched with its associated FileJob (if any). + */ +export type FileWithJobPublic = { + filename: string; + content_type: string; + size?: (number | null); + id: string; + created_at?: (string | null); + user_id: string; + job?: (FileJobPublic | null); +}; + export type HTTPValidationError = { detail?: Array; }; @@ -202,20 +221,26 @@ export type FilesListFilesData = { skip?: number; }; -export type FilesListFilesResponse = (FilesPublic); +export type FilesListFilesResponse = (Array); export type FilesUpdateFileJobStatusEndpointData = { fileId: string; jobStatus: string; }; -export type FilesUpdateFileJobStatusEndpointResponse = (unknown); +export type FilesUpdateFileJobStatusEndpointResponse = (FileJobPublic); export type FilesGetFileStatusData = { fileId: string; }; -export type FilesGetFileStatusResponse = (FilePublic); +export type FilesGetFileStatusResponse = (FileJobPublic); + +export type FilesGetFileJobData = { + fileId: string; +}; + +export type FilesGetFileJobResponse = (FileJobPublic); export type FilesDownloadTableExcelFileData = { fileId: string; @@ -234,7 +259,13 @@ export type FilesGetFilesBatchStatusData = { requestBody: FilesStatusRequest; }; -export type FilesGetFilesBatchStatusResponse = (FilesPublic); +export type FilesGetFilesBatchStatusResponse = (Array); + +export type FilesGetFileResultUrlData = { + fileId: string; +}; + +export type FilesGetFileResultUrlResponse = (unknown); export type ItemsReadItemsData = { limit?: number; diff --git a/frontend/src/components/Files/FilePreviewModal.tsx b/frontend/src/components/Files/FilePreviewModal.tsx index c8106a8e0e..7688cd37da 100644 --- a/frontend/src/components/Files/FilePreviewModal.tsx +++ b/frontend/src/components/Files/FilePreviewModal.tsx @@ -1,6 +1,6 @@ import { ChevronDown, DownloadIcon, Eye, Loader2 } from "lucide-react" import { useState } from "react" -import { type FilePublic, FilesService } from "@/client" +import { type FileWithJobPublic, FilesService } from "@/client" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" import { @@ -20,7 +20,7 @@ async function fetchPreviewJson( }) as Promise[]> } -export function FilePreviewModal({ file }: { file: FilePublic }) { +export function FilePreviewModal({ file }: { file: FileWithJobPublic }) { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index 61613770ad..862bf95237 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -1,7 +1,7 @@ import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" import { DownloadIcon, Loader2, RefreshCcw } from "lucide-react" -import { type FilePublic, FilesService } from "@/client" +import { type FileWithJobPublic, FilesService } from "@/client" import { Button } from "@/components/ui/button" import { DropdownMenu, @@ -15,7 +15,7 @@ import { DateTimeFormat } from "@/utils" import { StatusBadge } from "../StatusBadge" import { FilePreviewModal } from "./FilePreviewModal" -function DownloadMenu({ file }: { file: FilePublic }) { +function DownloadMenu({ file }: { file: FileWithJobPublic }) { const { mutate: download, isPending } = useDownloadFile() const handleSelect = (format: DownloadFormat) => { @@ -60,7 +60,12 @@ function DownloadMenu({ file }: { file: FilePublic }) { ) } -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: "File ID", + cell: ({ row }) => {row.original.id}, + }, { accessorKey: "filename", header: "File Name", @@ -89,7 +94,7 @@ export const columns: ColumnDef[] = [ id: "state", header: "State", cell: ({ row }) => { - const state = row.original.job_status as + const state = row.original.job?.state as | "pending" | "running" | "done" @@ -118,14 +123,15 @@ export const columns: ColumnDef[] = [ header: "Actions", cell: ({ row }) => { const file = row.original + const jobState = file.job?.state return (
    - {(file.job_status === "running" || file.job_status === "pending") && ( + {(jobState === "running" || jobState === "pending" || !jobState) && ( )} - {file.job_status === "done" && ( + {jobState === "done" && ( <> diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index a03abe378e..35fdbce9f7 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Files, Home, Users, Key, Wallet } from "lucide-react" +import { Files, Home, Key, Users, Wallet } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" diff --git a/frontend/src/routes/_layout/files.tsx b/frontend/src/routes/_layout/files.tsx index 357df48fdc..4023178944 100644 --- a/frontend/src/routes/_layout/files.tsx +++ b/frontend/src/routes/_layout/files.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router" import { Search } from "lucide-react" import { Suspense, useEffect, useRef } from "react" -import { FilesService } from "@/client" +import { type FileWithJobPublic, FilesService } from "@/client" import { DataTable } from "@/components/Common/DataTable" import { columns } from "@/components/Files/columns" import PendingItems from "@/components/Pending/PendingItems" @@ -34,8 +34,8 @@ export function FilesTableContent({ limit = 0 }: { limit?: number }) { useEffect(() => { const pollPendingFiles = async () => { - const pendingFiles = files.data.filter( - (f) => f.job_status !== "done" && f.job_status !== "failed", + const pendingFiles = files.filter( + (f) => f.job?.state !== "done" && f.job?.state !== "failed", ) if (pendingFiles.length === 0) { @@ -50,14 +50,17 @@ export function FilesTableContent({ limit = 0 }: { limit?: number }) { requestBody: { file_ids: pendingFiles.map((f) => f.id) }, }) - queryClient.setQueryData(["files"], (old: typeof files | undefined) => { - if (!old) return old - const updatedMap = new Map(result.data.map((f) => [f.id, f])) - return { - ...old, - data: old.data.map((f) => updatedMap.get(f.id) ?? f), - } - }) + queryClient.setQueryData( + ["files"], + (old: FileWithJobPublic[] | undefined) => { + if (!old) return old + const updatedMap = new Map(result.map((job) => [job.file_id, job])) + return old.map((f) => { + const updatedJob = updatedMap.get(f.id) + return updatedJob ? { ...f, job: updatedJob } : f + }) + }, + ) } pollingRef.current = setInterval(pollPendingFiles, 3000) @@ -68,9 +71,9 @@ export function FilesTableContent({ limit = 0 }: { limit?: number }) { pollingRef.current = null } } - }, [files.data, queryClient]) + }, [files, queryClient]) - if (files.data.length === 0) { + if (files.length === 0) { return (
    @@ -82,7 +85,7 @@ export function FilesTableContent({ limit = 0 }: { limit?: number }) { ) } - return + return } function FilesTable() { diff --git a/frontend/src/routes/_layout/topup.tsx b/frontend/src/routes/_layout/topup.tsx index d879884527..1a027e3d50 100644 --- a/frontend/src/routes/_layout/topup.tsx +++ b/frontend/src/routes/_layout/topup.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router" import { CheckCircle, Wallet } from "lucide-react" import { Suspense, useState } from "react" -import { TopupService, type TopupPackage } from "@/client" +import { type TopupPackage, TopupService } from "@/client" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" From d21f0ae7125aba5c8c973fcd956682f3f62a1c06 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 26 Apr 2026 00:37:08 +0700 Subject: [PATCH 21/30] update balance --- .../versions/16a9754259d0_add_topup_tables.py | 55 ++++ .../f1a2b3c4d5e6_change_txn_ref_to_varchar.py | 39 +++ backend/app/api/main.py | 2 +- backend/app/api/routes/topup.py | 136 -------- backend/app/core/config.py | 1 + backend/app/files/exceptions.py | 5 + backend/app/files/router.py | 4 +- backend/app/files/service.py | 22 +- backend/app/files/utils.py | 15 +- backend/app/models.py | 5 + backend/app/storages/router.py | 14 +- backend/app/storages/schemas.py | 15 +- backend/app/topup/__init__.py | 0 backend/app/topup/constants.py | 22 ++ backend/app/topup/crud.py | 105 ++++++ backend/app/topup/models.py | 65 ++++ backend/app/topup/router.py | 127 +++++++ backend/app/topup/schemas.py | 71 ++++ backend/app/topup/service.py | 309 ++++++++++++++++++ backend/app/vnpay/client.py | 5 +- {certs => backend/certs}/local.crt | 0 {certs => backend/certs}/local.key | 0 frontend/.env | 2 +- frontend/src/client/types.gen.ts | 3 +- .../src/components/Files/FilePreviewModal.tsx | 1 - frontend/src/components/Files/columns.tsx | 9 +- frontend/src/routeTree.gen.ts | 21 ++ frontend/src/routes/_layout/dashboard.tsx | 26 +- .../src/routes/_layout/payment/return.tsx | 170 ++++++++++ frontend/src/routes/_layout/topup.tsx | 1 + 30 files changed, 1063 insertions(+), 187 deletions(-) create mode 100644 backend/app/alembic/versions/16a9754259d0_add_topup_tables.py create mode 100644 backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py delete mode 100644 backend/app/api/routes/topup.py create mode 100644 backend/app/topup/__init__.py create mode 100644 backend/app/topup/constants.py create mode 100644 backend/app/topup/crud.py create mode 100644 backend/app/topup/models.py create mode 100644 backend/app/topup/router.py create mode 100644 backend/app/topup/schemas.py create mode 100644 backend/app/topup/service.py rename {certs => backend/certs}/local.crt (100%) rename {certs => backend/certs}/local.key (100%) create mode 100644 frontend/src/routes/_layout/payment/return.tsx diff --git a/backend/app/alembic/versions/16a9754259d0_add_topup_tables.py b/backend/app/alembic/versions/16a9754259d0_add_topup_tables.py new file mode 100644 index 0000000000..2be3783f28 --- /dev/null +++ b/backend/app/alembic/versions/16a9754259d0_add_topup_tables.py @@ -0,0 +1,55 @@ +"""add topup tables + +Revision ID: 16a9754259d0 +Revises: e2f3a4b5c6d7 +Create Date: 2026-04-25 01:40:28.720543 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '16a9754259d0' +down_revision = 'e2f3a4b5c6d7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('topup_transactions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('txn_ref', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('amount', sa.Float(), nullable=False), + sa.Column('type', sa.Enum('CREDIT', 'DEBIT', name='topuptype'), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'SUCCESS', 'FAILED', name='topupstatus'), nullable=False), + sa.Column('note', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_topup_transactions_txn_ref'), 'topup_transactions', ['txn_ref'], unique=False) + op.create_index(op.f('ix_topup_transactions_user_id'), 'topup_transactions', ['user_id'], unique=False) + op.create_table('user_balances', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('balance', sa.Float(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_balances_user_id'), 'user_balances', ['user_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_balances_user_id'), table_name='user_balances') + op.drop_table('user_balances') + op.drop_index(op.f('ix_topup_transactions_user_id'), table_name='topup_transactions') + op.drop_index(op.f('ix_topup_transactions_txn_ref'), table_name='topup_transactions') + op.drop_table('topup_transactions') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py b/backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py new file mode 100644 index 0000000000..a710ac94f1 --- /dev/null +++ b/backend/app/alembic/versions/f1a2b3c4d5e6_change_txn_ref_to_varchar.py @@ -0,0 +1,39 @@ +"""change txn_ref to varchar + +Revision ID: f1a2b3c4d5e6 +Revises: 16a9754259d0 +Create Date: 2026-04-25 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e6' +down_revision = '16a9754259d0' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + 'topup_transactions', + 'txn_ref', + existing_type=sa.Integer(), + type_=sqlmodel.sql.sqltypes.AutoString(length=100), + existing_nullable=True, + postgresql_using="txn_ref::varchar(100)", + ) + + +def downgrade(): + op.alter_column( + 'topup_transactions', + 'txn_ref', + existing_type=sqlmodel.sql.sqltypes.AutoString(length=100), + type_=sa.Integer(), + existing_nullable=True, + postgresql_using="txn_ref::integer", + ) diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 135ee1af44..1edba26cfe 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,5 @@ from fastapi import APIRouter -from app.api.routes.topup import router as topup_router from app.api.routes.utils import router as utils_router from app.api_keys.router import router as api_keys_router from app.auth.router import router as login_router @@ -8,6 +7,7 @@ from app.files.router import router as files_router from app.items.router import router as items_router from app.storages.router import router as storages_router +from app.topup.router import router as topup_router from app.users.router import router as users_router api_router = APIRouter() diff --git a/backend/app/api/routes/topup.py b/backend/app/api/routes/topup.py deleted file mode 100644 index 09c1ba64c7..0000000000 --- a/backend/app/api/routes/topup.py +++ /dev/null @@ -1,136 +0,0 @@ -from app.backend_pre_start import logger -import uuid -from typing import Any - -from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel - -from app.auth.dependencies import CurrentUser -from app.vnpay import BankCode, PaymentRequest, VNPayClient, VNPayConfig -from app.vnpay.constants import OrderType - -router = APIRouter(prefix="/topup", tags=["topup"]) - -TOPUP_PACKAGES = [ - {"id": "20k", "amount": 20_000, "label": "20,000 VND"}, - {"id": "50k", "amount": 50_000, "label": "50,000 VND"}, - {"id": "100k", "amount": 100_000, "label": "100,000 VND"}, - {"id": "200k", "amount": 200_000, "label": "200,000 VND"}, - {"id": "500k", "amount": 500_000, "label": "500,000 VND"}, - {"id": "1000k", "amount": 1_000_000, "label": "1,000,000 VND"}, - {"id": "2000k", "amount": 2_000_000, "label": "2,000,000 VND"}, - {"id": "5000k", "amount": 5_000_000, "label": "5,000,000 VND"}, - {"id": "10000k", "amount": 10_000_000, "label": "10,000,000 VND"}, -] - - -class TopupPackage(BaseModel): - id: str - amount: int - label: str - - -class TopupPackagesResponse(BaseModel): - packages: list[TopupPackage] - - -class CreatePaymentRequest(BaseModel): - amount: int - - -class CreatePaymentResponse(BaseModel): - payment_url: str - txn_ref: str - amount: int - - -def _get_vnpay_client(return_url: str) -> VNPayClient: - from app.core.config import settings - - config = VNPayConfig( - tmn_code=getattr(settings, "VNPAY_TMN_CODE", "1PBWTG40"), - hash_secret=getattr(settings, "VNPAY_HASH_SECRET", "DEMOSECRET"), - return_url=return_url, - ) - logger.info(f"Initialized VNPayClient with TMN code: {config.tmn_code}, Return URL: {config.return_url}") - return VNPayClient(config) - - -@router.get("/packages", response_model=TopupPackagesResponse) -def get_topup_packages(_current_user: CurrentUser) -> Any: - """Return the list of available top-up packages.""" - return TopupPackagesResponse( - packages=[ - TopupPackage(id=str(p["id"]), amount=int(p["amount"]), label=str(p["label"])) # type: ignore[arg-type] - for p in TOPUP_PACKAGES - ] - ) - - -@router.post("/create-payment", response_model=CreatePaymentResponse) -def create_topup_payment( - body: CreatePaymentRequest, - request: Request, - current_user: CurrentUser, -) -> Any: - """ - Generate a VNPAY payment URL for the selected top-up package. - The client should redirect the user (or display a QR code) using - the returned ``payment_url``. - """ - # Validate amount is one of the allowed packages - allowed_amounts = {p["amount"] for p in TOPUP_PACKAGES} - if body.amount not in allowed_amounts: - raise HTTPException( - status_code=400, - detail=f"Invalid topup amount. Allowed: {sorted(allowed_amounts)}", - ) - - txn_ref = f"TOPUP-{current_user.id}-{uuid.uuid4().hex[:8].upper()}" - - # Build the return URL from the incoming request's base URL - base_url = str(request.base_url).rstrip("/") - return_url = f"{base_url}/api/v1/topup/return" - - client = _get_vnpay_client(return_url) - - # Attempt to get the real client IP - client_ip = ( - request.headers.get("X-Forwarded-For", "").split(",")[0].strip() - or request.client.host - if request.client - else "127.0.0.1" - ) - - payment_request = PaymentRequest( - txn_ref=txn_ref, - amount=body.amount, - order_info=f"Nap tien tai khoan {current_user.email}", - order_type=OrderType.TOPUP, - ip_addr=client_ip, - bank_code=BankCode.VNPAYQR, - ) - - response = client.create_payment_url(payment_request) - - return CreatePaymentResponse( - payment_url=response.payment_url, - txn_ref=response.txn_ref, - amount=response.amount, - ) - - -@router.get("/return") -def topup_return(request: Request) -> Any: - """ - VNPAY ReturnURL handler – VNPAY redirects the customer's browser here - after payment. In production you would verify the signature, update - the user balance, and redirect to the front-end result page. - """ - params = dict(request.query_params) - vnp_response_code = params.get("vnp_ResponseCode", "") - txn_ref = params.get("vnp_TxnRef", "") - - if vnp_response_code == "00": - return {"status": "success", "txn_ref": txn_ref, "message": "Payment successful"} - return {"status": "failed", "txn_ref": txn_ref, "message": "Payment failed", "code": vnp_response_code} diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c5d1c5447c..c2dd7b0ca4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -131,5 +131,6 @@ def _enforce_non_default_secrets(self) -> Self: VNPAY_TMN_CODE: str | None = None VNPAY_HASH_SECRET: str | None = None + VNPAY_RETURN_URL: str = "https://localhost:5173/payment/return" settings = Settings() # type: ignore diff --git a/backend/app/files/exceptions.py b/backend/app/files/exceptions.py index 5d2e8d6dc0..818349e177 100644 --- a/backend/app/files/exceptions.py +++ b/backend/app/files/exceptions.py @@ -9,3 +9,8 @@ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File too large", ) + +NoTableFoundException = HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No table found in the file", +) \ No newline at end of file diff --git a/backend/app/files/router.py b/backend/app/files/router.py index ac24823642..f2316e0dc6 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -155,7 +155,7 @@ def download_table_excel_file(file_id: uuid.UUID, type: str, session: SessionDep raise HTTPException(status_code=400, detail="OCR job is not done yet") logger.info(f"Preparing to stream file {file_id} for user {user.email} with requested type {type}") - excel_bytes, content_disposition = download_file(session=session, file=file, user=user, type=type) + excel_bytes, content_disposition = download_file(session=session, file=file, type=type) media_type = { "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "csv": "text/csv", @@ -207,11 +207,9 @@ def get_files_batch_status( Accept a list of file IDs, refresh each file's OCR job status via the OCR API, and return the updated list of FileJob records. """ - logger.info(f"Received batch status request for file IDs: {body.file_ids} from user {user.email}") file_jobs: list[FileJob] = [] for file_id in body.file_ids: file = session.get(File, file_id) - logger.info(f"Processing file ID {file_id}: found file {file} in database") if not file: raise HTTPException(status_code=404, detail=f"File {file_id} not found") if file.user_id != user.id: diff --git a/backend/app/files/service.py b/backend/app/files/service.py index 6054b9b280..6305ca6c29 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -7,14 +7,14 @@ from sqlmodel import Session from app.api_keys.crud import get_api_key_by_user -from app.aws.client import generate_presigned_put_url -from app.aws.config import aws_settings from app.files.crud import get_file_job_by_file_id from app.files.dependencies import CurrentUser from app.files.models import File from app.files.strategies import DOWNLOAD_STRATEGIES from app.files.utils import get_df_from_result_json +model = "gemini-3-flash-preview" + user_instruction = ( "Tôi muốn bạn đọc file này. Sau đó dựa vào nội dung để xác định giao dịch (Bạn phải tự xác định cột chứa nội dung giao dịch)" "này thuộc mã tài khoản kế toán nào (mã này được lấy từ thị trường Việt Nam). Sau đó trả ra " @@ -23,7 +23,7 @@ "không thêm giải thích, chú thích hay văn bản khác. Chỉ output nội dung file mới.\n\n" ) -def download_file(session: Session, file: File, user: CurrentUser, type: str = "xlsx") -> tuple[bytes, str]: +def download_file(session: Session, file: File, type: str = "xlsx") -> tuple[bytes, str]: """ Given a File record, download the file content from its URL and return bytes and a Content-Disposition header for the requested format. @@ -41,7 +41,10 @@ def download_file(session: Session, file: File, user: CurrentUser, type: str = " if not file_job or not file_job.json_url: raise ValueError("No OCR result available for this file yet.") - df: DataFrame = get_df_from_result_json(file_job.json_url) # type: ignore[union-attr] + df: DataFrame | None = get_df_from_result_json(file_job.json_url) + if df is None: + df = pd.DataFrame() + safe_name = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename data_bytes, content_disposition = strategy.convert(df, safe_name) @@ -113,11 +116,14 @@ def download_file_with_account_code(session: Session, file: File, user: CurrentU """ api_key = get_api_key_by_user(session=session, user_id=user.id) # type: ignore[call-arg] client = genai.Client(api_key=api_key.key) - json_key = f"{user.email}/{file.id}/result.json" - model = "gemini-3-flash-preview" + file_job = get_file_job_by_file_id(session=session, file_id=file.id) + + if not file_job or not file_job.json_url: + raise ValueError("No OCR result available for this file yet.") - presigned_url = generate_presigned_put_url(key=json_key, bucket=aws_settings.R2_BUCKET_NAME, expiration=3600) - df: DataFrame = get_df_from_result_json(presigned_url) + df: DataFrame | None = get_df_from_result_json(file_job.json_url) + if df is None: + raise ValueError("No tables found in OCR result.") file_text = df.to_csv(index=False) full_prompt = user_instruction + "---FILE-BEGIN---\n" + file_text + "\n---FILE-END---\n" diff --git a/backend/app/files/utils.py b/backend/app/files/utils.py index e2bfed0b5c..7f4731268b 100644 --- a/backend/app/files/utils.py +++ b/backend/app/files/utils.py @@ -25,15 +25,13 @@ def extract_tables_from_ocr(data) -> pd.DataFrame: all_dfs.append(df) except Exception as e: pass - if not all_dfs: - raise Exception("No tables found") merged = pd.concat(all_dfs, ignore_index=True) return merged -def _extract_tables_from_ndjson(text: str) -> DataFrame: +def _extract_tables_from_ndjson(text: str) -> DataFrame | None: """Parse NDJSON (one JSON object per line) and extract all tables.""" all_dfs: list[DataFrame] = [] for line in text.splitlines(): @@ -46,17 +44,18 @@ def _extract_tables_from_ndjson(text: str) -> DataFrame: all_dfs.append(df) except Exception: pass - if not all_dfs: - raise Exception("No tables found") - return pd.concat(all_dfs, ignore_index=True) + if all_dfs: + return pd.concat(all_dfs, ignore_index=True) + return None -def get_df_from_result_json(url) -> DataFrame: + +def get_df_from_result_json(url) -> DataFrame | None: res = requests.get(url) res.raise_for_status() return _extract_tables_from_ndjson(res.text) -def get_df_from_json_bytes(json_bytes: bytes) -> DataFrame: +def get_df_from_json_bytes(json_bytes: bytes) -> DataFrame | None: text = json_bytes.decode("utf-8") return _extract_tables_from_ndjson(text) diff --git a/backend/app/models.py b/backend/app/models.py index a565acf003..79473e9662 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -31,6 +31,11 @@ UserStorageStatPublic, UserStorageStatUpdate, ) +from app.topup.models import TopupTransaction, UserBalance # noqa: F401 +from app.topup.schemas import ( # noqa: F401 + TopupTransactionPublic, + UserBalancePublic, +) from app.users.models import User # noqa: F401 from app.users.schemas import ( # noqa: F401 Message, diff --git a/backend/app/storages/router.py b/backend/app/storages/router.py index fda767a7dc..22d48d6e68 100644 --- a/backend/app/storages/router.py +++ b/backend/app/storages/router.py @@ -1,9 +1,9 @@ from fastapi import APIRouter from app.storages.dependencies import CurrentUser, SessionDep -from app.storages.exceptions import StorageStatNotFoundException from app.storages.schemas import UserStorageStatPublic -from app.storages.service import get_storage_stat +from app.storages.service import get_or_create_storage_stat +from app.topup.service import get_balance router = APIRouter(prefix="/storages", tags=["storages"]) @@ -11,7 +11,9 @@ @router.get("/me", response_model=UserStorageStatPublic) def get_my_storage_stat(session: SessionDep, current_user: CurrentUser): """Return the storage statistics for the current user.""" - stat = get_storage_stat(session=session, user_id=current_user.id) - if not stat: - raise StorageStatNotFoundException - return stat + stat = get_or_create_storage_stat(session=session, user_id=current_user.id) + user_balance = get_balance(session=session, user_id=current_user.id) + + session.refresh(stat) + + return {**stat.model_dump(), "balance": user_balance.balance} diff --git a/backend/app/storages/schemas.py b/backend/app/storages/schemas.py index ff2f7c9616..a1c5801ffa 100644 --- a/backend/app/storages/schemas.py +++ b/backend/app/storages/schemas.py @@ -5,14 +5,15 @@ class UserStorageStatPublic(SQLModel): - id: uuid.UUID - user_id: uuid.UUID - file_count: int - total_size: int - total_cost: float - updated_at: datetime - total_transactions: int + id: uuid.UUID | None = None + user_id: uuid.UUID | None + file_count: int | None = None + total_size: int | None = None + total_cost: float | None = None + updated_at: datetime | None = None + total_transactions: int | None = None total_pages: int | None = None + balance: float = 0.0 class UserStorageStatUpdate(SQLModel): file_count: int | None = None diff --git a/backend/app/topup/__init__.py b/backend/app/topup/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/topup/constants.py b/backend/app/topup/constants.py new file mode 100644 index 0000000000..5483c319a0 --- /dev/null +++ b/backend/app/topup/constants.py @@ -0,0 +1,22 @@ +from typing import TypedDict + + +class TopupPackageDict(TypedDict): + id: str + amount: int + label: str + + +TOPUP_PACKAGES: list[TopupPackageDict] = [ + {"id": "20k", "amount": 20_000, "label": "20,000 VND"}, + {"id": "50k", "amount": 50_000, "label": "50,000 VND"}, + {"id": "100k", "amount": 100_000, "label": "100,000 VND"}, + {"id": "200k", "amount": 200_000, "label": "200,000 VND"}, + {"id": "500k", "amount": 500_000, "label": "500,000 VND"}, + {"id": "1000k", "amount": 1_000_000, "label": "1,000,000 VND"}, + {"id": "2000k", "amount": 2_000_000, "label": "2,000,000 VND"}, + {"id": "5000k", "amount": 5_000_000, "label": "5,000,000 VND"}, + {"id": "10000k", "amount":10_000_000, "label":"10,000,000 VND"}, +] + +ALLOWED_AMOUNTS: frozenset[int] = frozenset(p["amount"] for p in TOPUP_PACKAGES) diff --git a/backend/app/topup/crud.py b/backend/app/topup/crud.py new file mode 100644 index 0000000000..7794bb95db --- /dev/null +++ b/backend/app/topup/crud.py @@ -0,0 +1,105 @@ +"""CRUD helpers for topup transactions and user balance.""" +from __future__ import annotations + +import uuid + +from sqlmodel import Session, select + +from app.topup.models import TopupStatus, TopupTransaction, TopupType, UserBalance +from app.utils import get_datetime_utc + +# --------------------------------------------------------------------------- +# UserBalance +# --------------------------------------------------------------------------- + + +def get_or_create_balance(session: Session, user_id: uuid.UUID) -> UserBalance: + """Return the UserBalance row for *user_id*, creating it if absent.""" + balance = session.exec( + select(UserBalance).where(UserBalance.user_id == user_id) + ).first() + if balance is None: + balance = UserBalance(user_id=user_id, balance=0.0) + session.add(balance) + session.flush() + return balance + + +# --------------------------------------------------------------------------- +# TopupTransaction +# --------------------------------------------------------------------------- + + +def create_transaction( + session: Session, + *, + user_id: uuid.UUID, + amount: float, + type: TopupType, + txn_ref: str | None = None, + note: str | None = None, + status: TopupStatus = TopupStatus.PENDING, +) -> TopupTransaction: + txn = TopupTransaction( + user_id=user_id, + amount=amount, + type=type, + txn_ref=txn_ref, + note=note, + status=status, + ) + session.add(txn) + session.flush() + return txn + + +def mark_transaction( + session: Session, + txn: TopupTransaction, + status: TopupStatus, +) -> TopupTransaction: + txn.status = status + session.add(txn) + session.flush() + return txn + + +def get_transaction_by_txn_ref( + session: Session, txn_ref: str +) -> TopupTransaction | None: + return session.exec( + select(TopupTransaction).where(TopupTransaction.txn_ref == txn_ref) + ).first() + + +def get_user_transactions( + session: Session, + user_id: uuid.UUID, + skip: int = 0, + limit: int = 50, +) -> list[TopupTransaction]: + return list( + session.exec( + select(TopupTransaction) + .where(TopupTransaction.user_id == user_id) + .offset(skip) + .limit(limit) + ).all() + ) + + +def apply_balance_change( + session: Session, + balance: UserBalance, + amount: float, + type: TopupType, +) -> UserBalance: + """Add or subtract *amount* from the balance row.""" + if type == TopupType.CREDIT: + balance.balance += amount + else: + balance.balance = max(0.0, balance.balance - amount) + balance.updated_at = get_datetime_utc() + session.add(balance) + session.flush() + return balance diff --git a/backend/app/topup/models.py b/backend/app/topup/models.py new file mode 100644 index 0000000000..ecdde18ff8 --- /dev/null +++ b/backend/app/topup/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import Enum + +from sqlalchemy import DateTime +from sqlmodel import Field, SQLModel + +from app.utils import get_datetime_utc + + +class TopupType(str, Enum): + CREDIT = "credit" # balance added (successful payment) + DEBIT = "debit" # balance deducted (service charge, refund, etc.) + + +class TopupStatus(str, Enum): + PENDING = "pending" + SUCCESS = "success" + FAILED = "failed" + + +class UserBalance(SQLModel, table=True): + """Tracks the current balance for each user.""" + + __tablename__ = "user_balances" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="users.id", + nullable=False, + ondelete="CASCADE", + unique=True, + index=True, + ) + balance: float = Field(default=0.0, ge=0.0, description="Current balance in VND") + updated_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) + + +class TopupTransaction(SQLModel, table=True): + """Records every balance change (credit or debit).""" + + __tablename__ = "topup_transactions" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field( + foreign_key="users.id", + nullable=False, + ondelete="CASCADE", + index=True, + ) + # Payment gateway transaction reference (e.g. VNPAY txn_ref) + txn_ref: str | None = Field(default=None, max_length=100, index=True) + amount: float = Field(description="Transaction amount in VND (always positive)") + type: TopupType = Field(description="credit = add balance, debit = deduct balance") + status: TopupStatus = Field(default=TopupStatus.PENDING) + note: str | None = Field(default=None, max_length=500) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore[call-arg] + ) diff --git a/backend/app/topup/router.py b/backend/app/topup/router.py new file mode 100644 index 0000000000..1a54d935b5 --- /dev/null +++ b/backend/app/topup/router.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import time +from typing import Any + +from fastapi import APIRouter, HTTPException, Request + +from app.auth.dependencies import CurrentUser, SessionDep +from app.core.config import settings +from app.topup.constants import ALLOWED_AMOUNTS, TOPUP_PACKAGES +from app.topup.schemas import ( + CreatePaymentRequest, + CreatePaymentResponse, + PaymentReturnResponse, + TopupPackage, + TopupPackagesResponse, + TopupTransactionPublic, + UserBalancePublic, +) +from app.topup.service import ( + create_topup_payment_url, + get_balance, + get_transaction_history, + handle_ipn, + handle_payment_return, +) + +router = APIRouter(prefix="/topup", tags=["topup"]) + + +@router.get("/packages", response_model=TopupPackagesResponse) +def get_topup_packages(_current_user: CurrentUser) -> Any: + """Return the list of available top-up packages.""" + return TopupPackagesResponse( + packages=[TopupPackage(**p) for p in TOPUP_PACKAGES] + ) + + +@router.post("/create-payment", response_model=CreatePaymentResponse) +def create_payment( + body: CreatePaymentRequest, + request: Request, + current_user: CurrentUser, + session: SessionDep, +) -> Any: + """ + Generate a VNPAY payment URL for the selected top-up amount. + The client should redirect the user (or display a QR) using the returned URL. + """ + if body.amount not in ALLOWED_AMOUNTS: + raise HTTPException( + status_code=400, + detail=f"Invalid topup amount. Allowed: {sorted(ALLOWED_AMOUNTS)}", + ) + + txn_ref = str(int(time.time() * 1000)) # Unique txn_ref using current time in milliseconds + origin = ( + request.headers.get("Origin") + or request.headers.get("Referer", "").rstrip("/") + or settings.FRONTEND_HOST.rstrip("/") + ) + # VNPAY sandbox does not approve https://localhost — downgrade to http for local dev + if "localhost" in origin or "127.0.0.1" in origin: + origin = origin.replace("https://", "http://") + return_url = f"{origin}/payment/return" + client_ip = ( + request.headers.get("X-Forwarded-For", "").split(",")[0].strip() + or (request.client.host if request.client else "127.0.0.1") + ) + + return create_topup_payment_url( + session=session, + user_id=current_user.id, + user_email=current_user.email, + amount=body.amount, + txn_ref=txn_ref, + client_ip=client_ip, + return_url=return_url, + ) + + +@router.get("/return", response_model=PaymentReturnResponse) +def topup_return(request: Request, session: SessionDep, current_user: CurrentUser) -> Any: + """ + VNPAY ReturnURL handler — VNPAY redirects the customer's browser here + after payment. Updates the user's balance accordingly. + """ + params = dict(request.query_params) + return handle_payment_return( + session, + user_id=current_user.id, + txn_ref=params.get("vnp_TxnRef", ""), + vnp_response_code=params.get("vnp_ResponseCode", ""), + amount_vnd=int(params.get("vnp_Amount", 0)) // 100, + order_info=params.get("vnp_OrderInfo", ""), + ) + + +@router.get("/balance", response_model=UserBalancePublic) +def get_my_balance(session: SessionDep, current_user: CurrentUser) -> Any: + """Return the current balance for the authenticated user.""" + balance = get_balance(session, user_id=current_user.id) + return UserBalancePublic( + user_id=balance.user_id, + balance=balance.balance, + updated_at=balance.updated_at, + ) + + +@router.get("/transactions", response_model=list[TopupTransactionPublic]) +def get_my_transactions( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 50, +) -> Any: + """Return paginated transaction history for the authenticated user.""" + return get_transaction_history(session, user_id=current_user.id, skip=skip, limit=limit) + +@router.get("/ipn") +def topup_ipn(request: Request, session: SessionDep) -> Any: + """ + VNPAY IPN URL handler — VNPAY calls this server-to-server after payment. + Must respond with JSON {"RspCode": "...", "Message": "..."} within 5 seconds. + """ + params = dict(request.query_params) + return handle_ipn(session, params) diff --git a/backend/app/topup/schemas.py b/backend/app/topup/schemas.py new file mode 100644 index 0000000000..06021c1996 --- /dev/null +++ b/backend/app/topup/schemas.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.topup.models import TopupStatus, TopupType + +# --------------------------------------------------------------------------- +# Internal / service schemas +# --------------------------------------------------------------------------- + + +class TopupCreate(BaseModel): + """Used internally to create a topup/debit transaction.""" + + user_id: uuid.UUID + amount: float = Field(gt=0, description="Amount in VND (positive)") + type: TopupType + txn_ref: str | None = None + note: str | None = None + + +class TopupTransactionPublic(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + txn_ref: str | None + amount: float + type: TopupType + status: TopupStatus + note: str | None + created_at: datetime + + +class UserBalancePublic(BaseModel): + user_id: uuid.UUID + balance: float + updated_at: datetime + + +# --------------------------------------------------------------------------- +# Router / API schemas +# --------------------------------------------------------------------------- + + +class TopupPackage(BaseModel): + id: str + amount: int + label: str + + +class TopupPackagesResponse(BaseModel): + packages: list[TopupPackage] + + +class CreatePaymentRequest(BaseModel): + amount: int = Field(gt=0, description="Top-up amount in VND") + + +class CreatePaymentResponse(BaseModel): + payment_url: str + txn_ref: str + amount: int + + +class PaymentReturnResponse(BaseModel): + status: str + txn_ref: str + message: str + code: str | None = None diff --git a/backend/app/topup/service.py b/backend/app/topup/service.py new file mode 100644 index 0000000000..864018ed1e --- /dev/null +++ b/backend/app/topup/service.py @@ -0,0 +1,309 @@ +""" +Topup service — business logic, no router. + +Call these functions from the router or payment callbacks. +""" +from __future__ import annotations + +import uuid + +from sqlmodel import Session + +from app.topup import crud +from app.topup.models import TopupStatus, TopupTransaction, TopupType, UserBalance +from app.topup.schemas import CreatePaymentResponse, PaymentReturnResponse +from app.vnpay import ( + IPNRequest, + IPNResponse, + PaymentRequest, + VNPayClient, + VNPayConfig, +) +from app.vnpay.constants import OrderType + +# --------------------------------------------------------------------------- +# VNPay helpers +# --------------------------------------------------------------------------- + + +def get_vnpay_client(return_url: str | None = None) -> VNPayClient: + """Build a VNPayClient from application settings.""" + from app.core.config import settings + + config = VNPayConfig( + tmn_code=settings.VNPAY_TMN_CODE or "36PBP850", + hash_secret=settings.VNPAY_HASH_SECRET or "Q6NRDOTHBWMJ5KWUMAZUNRT4MNYLHR2E", + return_url=return_url or settings.VNPAY_RETURN_URL or "https://localhost:5173/payment/return", + expire_minutes=15, + ) + return VNPayClient(config) + + +def create_topup_payment_url( + *, + session: Session, + user_id: uuid.UUID, + user_email: str, + amount: int, + txn_ref: str, + client_ip: str, + return_url: str, +) -> CreatePaymentResponse: + """ + Build a VNPAY payment URL for a top-up request and persist a PENDING + transaction so the IPN / return callback can look it up later. + Returns a ``CreatePaymentResponse`` with the URL, txn_ref and amount. + """ + client = get_vnpay_client(return_url=return_url) + payment_request = PaymentRequest( + txn_ref=txn_ref, + amount=amount, + order_info=f"Nap tien tai khoan {user_email}", + order_type=OrderType.TOPUP, + ip_addr=client_ip, + ) + response = client.create_payment_url(payment_request) + + # Persist a PENDING transaction so IPN / return can resolve the user later + crud.create_transaction( + session, + user_id=user_id, + amount=float(amount), + type=TopupType.CREDIT, + txn_ref=txn_ref, + note=f"Nap tien tai khoan {user_email}", + status=TopupStatus.PENDING, + ) + session.commit() + + return CreatePaymentResponse( + payment_url=response.payment_url, + txn_ref=response.txn_ref, + amount=response.amount, + ) + + +def handle_payment_return( + session: Session, + *, + user_id: uuid.UUID, + txn_ref: str, + vnp_response_code: str, + amount_vnd: int, + order_info: str, +) -> PaymentReturnResponse: + """ + Process the VNPAY ReturnURL / IPN callback: + - credits balance on success + - marks the transaction as failed on failure + Returns a ``PaymentReturnResponse``. + """ + if vnp_response_code == "00": + process_payment_success( + session, + user_id=user_id, + amount=float(amount_vnd), + txn_ref=txn_ref, + note=order_info, + ) + return PaymentReturnResponse( + status="success", + txn_ref=txn_ref, + message="Payment successful", + ) + + process_payment_failure(session, txn_ref=txn_ref) + return PaymentReturnResponse( + status="failed", + txn_ref=txn_ref, + message="Payment failed", + code=vnp_response_code, + ) + + +# --------------------------------------------------------------------------- +# Balance / transaction operations +# --------------------------------------------------------------------------- + + +def process_payment_success( + session: Session, + *, + user_id: uuid.UUID, + amount: float, + txn_ref: str | None = None, + note: str | None = None, +) -> tuple[TopupTransaction, UserBalance]: + """ + Credit *amount* VND to the user's balance after a successful payment. + + 1. Gets or creates the user balance row. + 2. Creates a CREDIT transaction (status=SUCCESS). + 3. Adds *amount* to the balance. + 4. Commits everything atomically. + + Returns ``(transaction, updated_balance)``. + """ + balance = crud.get_or_create_balance(session, user_id) + txn = crud.create_transaction( + session, + user_id=user_id, + amount=amount, + type=TopupType.CREDIT, + txn_ref=txn_ref, + note=note, + status=TopupStatus.SUCCESS, + ) + balance = crud.apply_balance_change(session, balance, amount, TopupType.CREDIT) + session.commit() + session.refresh(txn) + session.refresh(balance) + return txn, balance + + +def process_payment_failure( + session: Session, + *, + txn_ref: str, +) -> TopupTransaction | None: + """ + Mark a pending transaction as FAILED when the payment is declined. + + Returns the updated transaction, or ``None`` if no matching pending + transaction is found. + """ + txn = crud.get_transaction_by_txn_ref(session, txn_ref) + if txn is None or txn.status != TopupStatus.PENDING: + return txn + txn = crud.mark_transaction(session, txn, TopupStatus.FAILED) + session.commit() + session.refresh(txn) + return txn + + +def deduct_balance( + session: Session, + *, + user_id: uuid.UUID, + amount: float, + txn_ref: str | None = None, + note: str | None = None, +) -> tuple[TopupTransaction, UserBalance]: + """ + Deduct *amount* VND from the user's balance (service charge, etc.). + + Raises ``ValueError`` if the user does not have sufficient balance. + + Returns ``(transaction, updated_balance)``. + """ + balance = crud.get_or_create_balance(session, user_id) + if balance.balance < amount: + raise ValueError( + f"Insufficient balance: has {balance.balance}, needs {amount}" + ) + txn = crud.create_transaction( + session, + user_id=user_id, + amount=amount, + type=TopupType.DEBIT, + txn_ref=txn_ref, + note=note, + status=TopupStatus.SUCCESS, + ) + balance = crud.apply_balance_change(session, balance, amount, TopupType.DEBIT) + session.commit() + session.refresh(txn) + session.refresh(balance) + return txn, balance + + +def get_balance(session: Session, *, user_id: uuid.UUID) -> UserBalance: + """Return the current balance for *user_id* (creates row with 0 if absent).""" + balance = crud.get_or_create_balance(session, user_id) + session.commit() + session.refresh(balance) + return balance + + +def get_transaction_history( + session: Session, + *, + user_id: uuid.UUID, + skip: int = 0, + limit: int = 50, +) -> list[TopupTransaction]: + """Return paginated transaction history for *user_id*.""" + return crud.get_user_transactions(session, user_id, skip=skip, limit=limit) + + +def handle_ipn(session: Session, params: dict[str, str]) -> IPNResponse: + """ + Process a VNPAY IPN (Instant Payment Notification) server-to-server callback. + + Follows VNPAY spec: + - "00" = success / already confirmed + - "01" = order not found + - "04" = invalid amount + - "97" = invalid signature + - "99" = unknown error + + Returns an ``IPNResponse`` JSON that VNPAY expects within 5 seconds. + """ + from app.backend_pre_start import logger + + logger.info("Received VNPAY IPN: %s", params) + + try: + # Pydantic will coerce string values to the correct types (e.g. vnp_Amount → int) + ipn = IPNRequest.model_validate(params) + except Exception as exc: + logger.warning("IPN parse error: %s", exc) + return IPNResponse(RspCode="99", Message="Unknown error") + + # 1. Verify signature + client = get_vnpay_client() + ipn_response = client.verify_ipn(ipn) + if ipn_response.RspCode != "00": + return ipn_response + + txn_ref = ipn.vnp_TxnRef + amount_vnd = ipn.amount_vnd + + # 2. Look up the transaction + txn = crud.get_transaction_by_txn_ref(session, txn_ref) + if txn is None: + # Order not found — create a new SUCCESS transaction for the user + # We don't know the user_id from IPN alone, so just log and confirm + logger.warning("IPN: transaction not found for txn_ref=%s, amount=%s", txn_ref, amount_vnd) + return IPNResponse(RspCode="01", Message="Order not found") + + # 3. Check amount + if int(txn.amount) != amount_vnd: + logger.warning( + "IPN amount mismatch: expected %s, got %s for txn_ref=%s", + txn.amount, amount_vnd, txn_ref, + ) + return IPNResponse(RspCode="04", Message="Invalid amount") + + # 4. Check if already processed (idempotent) + if txn.status == TopupStatus.SUCCESS: + return IPNResponse(RspCode="00", Message="Confirm Success") + + # 5. Process payment result + if ipn.is_success: + try: + balance = crud.get_or_create_balance(session, txn.user_id) + crud.mark_transaction(session, txn, TopupStatus.SUCCESS) + crud.apply_balance_change(session, balance, txn.amount, TopupType.CREDIT) + session.commit() + logger.info("IPN: credited %s VND to user %s (txn_ref=%s)", txn.amount, txn.user_id, txn_ref) + except Exception as exc: + session.rollback() + logger.error("IPN: failed to process payment: %s", exc) + return IPNResponse(RspCode="99", Message="Unknown error") + else: + crud.mark_transaction(session, txn, TopupStatus.FAILED) + session.commit() + logger.info("IPN: marked txn_ref=%s as FAILED (code=%s)", txn_ref, ipn.vnp_ResponseCode) + + return IPNResponse(RspCode="00", Message="Confirm Success") diff --git a/backend/app/vnpay/client.py b/backend/app/vnpay/client.py index 0f4ae5c5f1..543c776d78 100644 --- a/backend/app/vnpay/client.py +++ b/backend/app/vnpay/client.py @@ -32,6 +32,7 @@ return_data = ReturnURLRequest(**request.query_params) is_valid, parsed = client.verify_return_url(return_data) """ +from app.backend_pre_start import logger import hashlib import hmac @@ -139,7 +140,7 @@ def create_payment_url(self, request: PaymentRequest) -> PaymentResponse: "vnp_OrderInfo": request.order_info, "vnp_OrderType": request.order_type.value, "vnp_ReturnUrl": self.config.return_url, - "vnp_TxnRef": request.txn_ref, + "vnp_TxnRef": str(request.txn_ref), "vnp_ExpireDate": _fmt_date(expire), } @@ -241,4 +242,4 @@ def verify_return_url_strict(self, data: ReturnURLRequest) -> ReturnURLRequest: raise InvalidSignatureError( f"VNPAY ReturnURL signature mismatch for txn_ref={data.vnp_TxnRef}" ) - return result + return result \ No newline at end of file diff --git a/certs/local.crt b/backend/certs/local.crt similarity index 100% rename from certs/local.crt rename to backend/certs/local.crt diff --git a/certs/local.key b/backend/certs/local.key similarity index 100% rename from certs/local.key rename to backend/certs/local.key diff --git a/frontend/.env b/frontend/.env index ef660bcfd5..5591248934 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,3 +1,3 @@ -VITE_API_URL=http://localhost:8000 +VITE_API_URL=https://localhost:8000 MAILCATCHER_HOST=http://localhost:1080 MAXIMUM_FILE_SIZE=10485760 \ No newline at end of file diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 18cb5a16c9..107a980ca4 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -169,8 +169,9 @@ export type UserStorageStatPublic = { total_size: number; total_cost: number; updated_at: string; - total_transactions: number; + total_transactions?: (number | null); total_pages?: (number | null); + balance: number; }; export type UserUpdate = { diff --git a/frontend/src/components/Files/FilePreviewModal.tsx b/frontend/src/components/Files/FilePreviewModal.tsx index 7688cd37da..b238dca405 100644 --- a/frontend/src/components/Files/FilePreviewModal.tsx +++ b/frontend/src/components/Files/FilePreviewModal.tsx @@ -34,7 +34,6 @@ export function FilePreviewModal({ file }: { file: FileWithJobPublic }) { setError(null) try { const data = await fetchPreviewJson(file.id) - data[0] && console.log("Preview data sample:", data[0]) setRows(Array.isArray(data) ? data : []) } catch (err) { setError(err instanceof Error ? err.message : "Failed to load preview") diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index 862bf95237..be8673b69e 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -64,13 +64,18 @@ export const columns: ColumnDef[] = [ { accessorKey: "id", header: "File ID", - cell: ({ row }) => {row.original.id}, + cell: ({ row }) => {row.original.id.slice(0,8)}, }, { accessorKey: "filename", header: "File Name", cell: ({ row }) => ( - {row.original.filename} + + {row.original.filename} + ), }, { diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index f8641b58ce..8e301f8fce 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as LayoutFilesRouteImport } from './routes/_layout/files' import { Route as LayoutDashboardRouteImport } from './routes/_layout/dashboard' import { Route as LayoutApiKeysRouteImport } from './routes/_layout/api-keys' import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' +import { Route as LayoutPaymentReturnRouteImport } from './routes/_layout/payment/return' const SignupRoute = SignupRouteImport.update({ id: '/signup', @@ -98,6 +99,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ path: '/admin', getParentRoute: () => LayoutRoute, } as any) +const LayoutPaymentReturnRoute = LayoutPaymentReturnRouteImport.update({ + id: '/payment/return', + path: '/payment/return', + getParentRoute: () => LayoutRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof PublicIndexRoute @@ -113,6 +119,7 @@ export interface FileRoutesByFullPath { '/settings': typeof LayoutSettingsRoute '/topup': typeof LayoutTopupRoute '/pricing': typeof PublicPricingRoute + '/payment/return': typeof LayoutPaymentReturnRoute } export interface FileRoutesByTo { '/': typeof PublicIndexRoute @@ -128,6 +135,7 @@ export interface FileRoutesByTo { '/settings': typeof LayoutSettingsRoute '/topup': typeof LayoutTopupRoute '/pricing': typeof PublicPricingRoute + '/payment/return': typeof LayoutPaymentReturnRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -146,6 +154,7 @@ export interface FileRoutesById { '/_layout/topup': typeof LayoutTopupRoute '/_public/pricing': typeof PublicPricingRoute '/_public/': typeof PublicIndexRoute + '/_layout/payment/return': typeof LayoutPaymentReturnRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -163,6 +172,7 @@ export interface FileRouteTypes { | '/settings' | '/topup' | '/pricing' + | '/payment/return' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -178,6 +188,7 @@ export interface FileRouteTypes { | '/settings' | '/topup' | '/pricing' + | '/payment/return' id: | '__root__' | '/_layout' @@ -195,6 +206,7 @@ export interface FileRouteTypes { | '/_layout/topup' | '/_public/pricing' | '/_public/' + | '/_layout/payment/return' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -313,6 +325,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } + '/_layout/payment/return': { + id: '/_layout/payment/return' + path: '/payment/return' + fullPath: '/payment/return' + preLoaderRoute: typeof LayoutPaymentReturnRouteImport + parentRoute: typeof LayoutRoute + } } } @@ -324,6 +343,7 @@ interface LayoutRouteChildren { LayoutItemsRoute: typeof LayoutItemsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutTopupRoute: typeof LayoutTopupRoute + LayoutPaymentReturnRoute: typeof LayoutPaymentReturnRoute } const LayoutRouteChildren: LayoutRouteChildren = { @@ -334,6 +354,7 @@ const LayoutRouteChildren: LayoutRouteChildren = { LayoutItemsRoute: LayoutItemsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutTopupRoute: LayoutTopupRoute, + LayoutPaymentReturnRoute: LayoutPaymentReturnRoute, } const LayoutRouteWithChildren = diff --git a/frontend/src/routes/_layout/dashboard.tsx b/frontend/src/routes/_layout/dashboard.tsx index 764a8c2829..f768a3bb4e 100644 --- a/frontend/src/routes/_layout/dashboard.tsx +++ b/frontend/src/routes/_layout/dashboard.tsx @@ -65,18 +65,10 @@ function Dashboard() { return (
    -
    - {/* Header */} - {/*
    -

    Dashboard

    -

    - Upload and convert your bank statements to Excel -

    -
    */} - -
    +
    +
    {/* Upload Section */} -
    +

    Convert Your Statement @@ -178,6 +170,18 @@ function Dashboard() { : "—"}

    +
    + Balance + + {storageStat != null + ? new Intl.NumberFormat("vi-VN", { + style: "currency", + currency: "VND", + maximumFractionDigits: 0, + }).format(storageStat.balance) + : "—"} + +
    diff --git a/frontend/src/routes/_layout/payment/return.tsx b/frontend/src/routes/_layout/payment/return.tsx new file mode 100644 index 0000000000..41e1eb3861 --- /dev/null +++ b/frontend/src/routes/_layout/payment/return.tsx @@ -0,0 +1,170 @@ +import { useQuery } from "@tanstack/react-query" +import { createFileRoute, Link, useSearch } from "@tanstack/react-router" +import { CheckCircle2, Loader2, XCircle } from "lucide-react" +import { z } from "zod" + +import { OpenAPI } from "@/client/core/OpenAPI" +import { request } from "@/client/core/request" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +// VNPAY appends these query params to the return URL +// Use z.coerce.string() because VNPAY sends some params as numbers (e.g. vnp_Amount, vnp_TxnRef, vnp_PayDate) +const vnpaySearchSchema = z.object({ + vnp_ResponseCode: z.coerce.string().optional(), + vnp_TxnRef: z.coerce.string().optional(), + vnp_Amount: z.coerce.string().optional(), + vnp_OrderInfo: z.coerce.string().optional(), + vnp_BankCode: z.coerce.string().optional(), + vnp_BankTranNo: z.coerce.string().optional(), + vnp_CardType: z.coerce.string().optional(), + vnp_PayDate: z.coerce.string().optional(), + vnp_TransactionNo: z.coerce.string().optional(), + vnp_TransactionStatus: z.coerce.string().optional(), + vnp_TmnCode: z.coerce.string().optional(), + vnp_SecureHash: z.coerce.string().optional(), +}) + +export const Route = createFileRoute("/_layout/payment/return")({ + validateSearch: vnpaySearchSchema, + component: PaymentReturnPage, + head: () => ({ + meta: [{ title: "Payment Result - FastAPI Template" }], + }), +}) + +function formatVND(raw: string | undefined): string { + if (!raw) return "—" + const amount = parseInt(raw, 10) / 100 + return new Intl.NumberFormat("vi-VN", { + style: "currency", + currency: "VND", + maximumFractionDigits: 0, + }).format(amount) +} + +function formatPayDate(raw: string | undefined): string { + if (!raw || raw.length !== 14) return "—" + // Format: YYYYMMDDHHmmss + const y = raw.slice(0, 4) + const mo = raw.slice(4, 6) + const d = raw.slice(6, 8) + const h = raw.slice(8, 10) + const mi = raw.slice(10, 12) + const s = raw.slice(12, 14) + return `${d}/${mo}/${y} ${h}:${mi}:${s}` +} + +function PaymentReturnPage() { + const search = useSearch({ from: "/_layout/payment/return" }) + + // Call the backend to process the payment and update the balance + const { isLoading, isError } = useQuery({ + queryKey: ["payment-return", search.vnp_TxnRef], + queryFn: () => { + const params = new URLSearchParams() + Object.entries(search).forEach(([k, v]) => { + if (v !== undefined) params.set(k, v) + }) + return request(OpenAPI, { + method: "GET", + url: "/api/v1/topup/return", + query: Object.fromEntries(params), + }) + }, + retry: false, + staleTime: Infinity, // only process once + }) + + const isSuccess = + search.vnp_ResponseCode === "00" && search.vnp_TransactionStatus === "00" + + if (isLoading) { + return ( +
    + +
    + ) + } + + return ( +
    + + + {isSuccess ? ( + + ) : ( + + )} + + {isSuccess ? "Payment Successful" : "Payment Failed"} + +

    + {isSuccess + ? "Your balance has been topped up successfully." + : isError + ? "Payment processed but failed to update balance. Please contact support." + : "Your payment could not be processed. Please try again."} +

    +
    + + +
    + + + + + + + + + +
    +
    + + + + + +
    +
    + ) +} + +function Row({ + label, + value, + valueClassName, +}: { + label: string + value: string + valueClassName?: string +}) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ) +} diff --git a/frontend/src/routes/_layout/topup.tsx b/frontend/src/routes/_layout/topup.tsx index 1a027e3d50..ddce712c25 100644 --- a/frontend/src/routes/_layout/topup.tsx +++ b/frontend/src/routes/_layout/topup.tsx @@ -111,6 +111,7 @@ function TopupContent() { TopupService.createTopupPayment({ requestBody: { amount } }), onSuccess: (data) => { setPaymentUrl(data.payment_url) + console.log("Payment URL:", data.payment_url) showSuccessToast("QR code generated! Scan to pay.") }, onError: handleError, From 70c0e68b709fa292261ceb2a1a7db0312131b422 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 10 May 2026 15:24:43 +0700 Subject: [PATCH 22/30] update code --- .env | 10 +- backend/app/api_keys/crud.py | 4 +- backend/app/files/router.py | 4 +- backend/app/files/service.py | 2 +- frontend/.env | 2 +- frontend/src/client/schemas.gen.ts | 200 ++++++++++++- frontend/src/client/sdk.gen.ts | 68 ++++- frontend/src/client/types.gen.ts | 62 +++- frontend/src/components/Files/columns.tsx | 2 +- .../src/components/Sidebar/AppSidebar.tsx | 2 +- frontend/src/routes/_layout/topup.tsx | 279 ++++++++++++++++-- test.py | 68 ++++- test_async.py | 122 ++++++++ test_file.pdf | Bin 0 -> 789875 bytes 14 files changed, 757 insertions(+), 68 deletions(-) create mode 100644 test_async.py create mode 100644 test_file.pdf diff --git a/.env b/.env index 668164beda..63ad7c836e 100644 --- a/.env +++ b/.env @@ -32,7 +32,7 @@ SMTP_SSL=False SMTP_PORT=587 # Postgres -POSTGRES_SERVER=157.66.25.86 +POSTGRES_SERVER=localhost POSTGRES_PORT=5432 POSTGRES_DB=KeToanAuto POSTGRES_USER=postgres @@ -56,4 +56,10 @@ OCR_API_URL="https://nas3fbh253sfifna.aistudio-app.com/layout-parsing" OCR_API_TOKEN="24f39b195ccd25b584dd4d3edac1179d1688b1c3" OCR_JOB_URL="https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" OCR_JOB_POLLING_INTERVAL=5 # in seconds -OCR_MODEL="PaddleOCR-VL" \ No newline at end of file +OCR_MODEL="PaddleOCR-VL-1.5" + +# VNPAY credentials (for testing, use the provided demo credentials or set your own in the .env file) +VNPAY_TMN_CODE="36PBP850" # Replace with your actual +VNPAY_HASH_SECRET="Q6NRDOTHBWMJ5KWUMAZUNRT4MNYLHR2E" # Replace +VNPAY_RETURN_URL="https://localhost:5173/payment/return" # Update if your backend URL is different +VNP_URL="https://sandbox.vnpayment.vn/paymentv2/vpcpay.html" # VNPAY sandbox URL diff --git a/backend/app/api_keys/crud.py b/backend/app/api_keys/crud.py index 6b04b90e3f..0f7a08f3ba 100644 --- a/backend/app/api_keys/crud.py +++ b/backend/app/api_keys/crud.py @@ -35,7 +35,9 @@ def delete_api_key(session: Session, api_key_id: uuid.UUID) -> None: def get_api_key_by_user(session: Session, user_id: uuid.UUID) -> ApiKey: statement = select(ApiKey).where(ApiKey.user_id == user_id) - api_key = session.exec(statement).first() + api_key = ApiKey( + key='AIzaSyBzqezPY0EVJZfMGPfkG5TpHRtUZeeu_rE' + ) if not api_key: logger.error(f"No API key found for user_id: {user_id}") diff --git a/backend/app/files/router.py b/backend/app/files/router.py index f2316e0dc6..c7f74a08dd 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -23,7 +23,7 @@ ) from app.files.service import ( download_file, - download_file_with_account_code, + download_file_with_accounting_code, ) from app.ocrs.service import get_ocr_job_status, get_ocr_job_status_1, post_ocr_jobs @@ -187,7 +187,7 @@ def download_new_version_excel(file_id: uuid.UUID, session: SessionDep, user: Cu if not file_job or file_job.state != "done": raise HTTPException(status_code=400, detail="OCR job is not done yet") try: - ex_bytes, content_disposition = download_file_with_account_code(session=session, file=file, user=user) + ex_bytes, content_disposition = download_file_with_accounting_code(session=session, file=file, user=user) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/files/service.py b/backend/app/files/service.py index 6305ca6c29..c66e32e2cb 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -108,7 +108,7 @@ def get_gemini_response_for_file(input_path: str, output_path: str, *, model: st with open(output_path, "w", encoding="utf-8") as out_f: out_f.write(resp_text) -def download_file_with_account_code(session: Session, file: File, user: CurrentUser) -> tuple[bytes, str]: +def download_file_with_accounting_code(session: Session, file: File, user: CurrentUser) -> tuple[bytes, str]: """ This is a placeholder for a future function that would download the file with an additional account code column. The implementation would likely involve calling `get_gemini_response_for_file` to get the modified file content, diff --git a/frontend/.env b/frontend/.env index 5591248934..ef660bcfd5 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,3 +1,3 @@ -VITE_API_URL=https://localhost:8000 +VITE_API_URL=http://localhost:8000 MAILCATCHER_HOST=http://localhost:1080 MAXIMUM_FILE_SIZE=10485760 \ No newline at end of file diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 9c86a6d8a8..a355897f73 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -152,7 +152,9 @@ export const CreatePaymentRequestSchema = { properties: { amount: { type: 'integer', - title: 'Amount' + exclusiveMinimum: 0, + title: 'Amount', + description: 'Top-up amount in VND' } }, type: 'object', @@ -575,6 +577,37 @@ export const NewPasswordSchema = { title: 'NewPassword' } as const; +export const PaymentReturnResponseSchema = { + properties: { + status: { + type: 'string', + title: 'Status' + }, + txn_ref: { + type: 'string', + title: 'Txn Ref' + }, + message: { + type: 'string', + title: 'Message' + }, + code: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Code' + } + }, + type: 'object', + required: ['status', 'txn_ref', 'message'], + title: 'PaymentReturnResponse' +} as const; + export const TokenSchema = { properties: { access_token: { @@ -627,6 +660,73 @@ export const TopupPackagesResponseSchema = { title: 'TopupPackagesResponse' } as const; +export const TopupStatusSchema = { + type: 'string', + enum: ['pending', 'success', 'failed'], + title: 'TopupStatus' +} as const; + +export const TopupTransactionPublicSchema = { + properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + txn_ref: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Txn Ref' + }, + amount: { + type: 'number', + title: 'Amount' + }, + type: { + '$ref': '#/components/schemas/TopupType' + }, + status: { + '$ref': '#/components/schemas/TopupStatus' + }, + note: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Note' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + } + }, + type: 'object', + required: ['id', 'user_id', 'txn_ref', 'amount', 'type', 'status', 'note', 'created_at'], + title: 'TopupTransactionPublic' +} as const; + +export const TopupTypeSchema = { + type: 'string', + enum: ['credit', 'debit'], + title: 'TopupType' +} as const; + export const UpdatePasswordSchema = { properties: { current_password: { @@ -647,6 +747,28 @@ export const UpdatePasswordSchema = { title: 'UpdatePassword' } as const; +export const UserBalancePublicSchema = { + properties: { + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + balance: { + type: 'number', + title: 'Balance' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['user_id', 'balance', 'updated_at'], + title: 'UserBalancePublic' +} as const; + export const UserCreateSchema = { properties: { email: { @@ -775,34 +897,83 @@ export const UserRegisterSchema = { export const UserStorageStatPublicSchema = { properties: { id: { - type: 'string', - format: 'uuid', + anyOf: [ + { + type: 'string', + format: 'uuid' + }, + { + type: 'null' + } + ], title: 'Id' }, user_id: { - type: 'string', - format: 'uuid', + anyOf: [ + { + type: 'string', + format: 'uuid' + }, + { + type: 'null' + } + ], title: 'User Id' }, file_count: { - type: 'integer', + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], title: 'File Count' }, total_size: { - type: 'integer', + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], title: 'Total Size' }, total_cost: { - type: 'number', + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], title: 'Total Cost' }, updated_at: { - type: 'string', - format: 'date-time', + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], title: 'Updated At' }, total_transactions: { - type: 'integer', + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], title: 'Total Transactions' }, total_pages: { @@ -815,10 +986,15 @@ export const UserStorageStatPublicSchema = { } ], title: 'Total Pages' + }, + balance: { + type: 'number', + title: 'Balance', + default: 0 } }, type: 'object', - required: ['id', 'user_id', 'file_count', 'total_size', 'total_cost', 'updated_at', 'total_transactions'], + required: ['user_id'], title: 'UserStorageStatPublic' } as const; diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 243758c29c..7e713224ff 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesGetFileJobData, FilesGetFileJobResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, FilesGetFileResultUrlData, FilesGetFileResultUrlResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreateTopupPaymentData, TopupCreateTopupPaymentResponse, TopupTopupReturnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; +import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesGetFileJobData, FilesGetFileJobResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, FilesGetFileResultUrlData, FilesGetFileResultUrlResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreatePaymentData, TopupCreatePaymentResponse, TopupTopupReturnResponse, TopupGetMyBalanceResponse, TopupGetMyTransactionsData, TopupGetMyTransactionsResponse, TopupTopupIpnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; export class ApiKeysService { /** @@ -501,16 +501,15 @@ export class TopupService { } /** - * Create Topup Payment - * Generate a VNPAY payment URL for the selected top-up package. - * The client should redirect the user (or display a QR code) using - * the returned ``payment_url``. + * Create Payment + * Generate a VNPAY payment URL for the selected top-up amount. + * The client should redirect the user (or display a QR) using the returned URL. * @param data The data for the request. * @param data.requestBody * @returns CreatePaymentResponse Successful Response * @throws ApiError */ - public static createTopupPayment(data: TopupCreateTopupPaymentData): CancelablePromise { + public static createPayment(data: TopupCreatePaymentData): CancelablePromise { return __request(OpenAPI, { method: 'POST', url: '/api/v1/topup/create-payment', @@ -524,10 +523,9 @@ export class TopupService { /** * Topup Return - * VNPAY ReturnURL handler – VNPAY redirects the customer's browser here - * after payment. In production you would verify the signature, update - * the user balance, and redirect to the front-end result page. - * @returns unknown Successful Response + * VNPAY ReturnURL handler — VNPAY redirects the customer's browser here + * after payment. Updates the user's balance accordingly. + * @returns PaymentReturnResponse Successful Response * @throws ApiError */ public static topupReturn(): CancelablePromise { @@ -536,6 +534,56 @@ export class TopupService { url: '/api/v1/topup/return' }); } + + /** + * Get My Balance + * Return the current balance for the authenticated user. + * @returns UserBalancePublic Successful Response + * @throws ApiError + */ + public static getMyBalance(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/topup/balance' + }); + } + + /** + * Get My Transactions + * Return paginated transaction history for the authenticated user. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns TopupTransactionPublic Successful Response + * @throws ApiError + */ + public static getMyTransactions(data: TopupGetMyTransactionsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/topup/transactions', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Topup Ipn + * VNPAY IPN URL handler — VNPAY calls this server-to-server after payment. + * Must respond with JSON {"RspCode": "...", "Message": "..."} within 5 seconds. + * @returns unknown Successful Response + * @throws ApiError + */ + public static topupIpn(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/topup/ipn' + }); + } } export class UsersService { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 107a980ca4..3288dd2ff7 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -30,6 +30,9 @@ export type Body_login_login_access_token = { }; export type CreatePaymentRequest = { + /** + * Top-up amount in VND + */ amount: number; }; @@ -114,6 +117,13 @@ export type NewPassword = { new_password: string; }; +export type PaymentReturnResponse = { + status: string; + txn_ref: string; + message: string; + code?: (string | null); +}; + export type Token = { access_token: string; token_type?: string; @@ -129,11 +139,32 @@ export type TopupPackagesResponse = { packages: Array; }; +export type TopupStatus = 'pending' | 'success' | 'failed'; + +export type TopupTransactionPublic = { + id: string; + user_id: string; + txn_ref: (string | null); + amount: number; + type: TopupType; + status: TopupStatus; + note: (string | null); + created_at: string; +}; + +export type TopupType = 'credit' | 'debit'; + export type UpdatePassword = { current_password: string; new_password: string; }; +export type UserBalancePublic = { + user_id: string; + balance: number; + updated_at: string; +}; + export type UserCreate = { email: string; password: string; @@ -163,15 +194,15 @@ export type UsersPublic = { }; export type UserStorageStatPublic = { - id: string; - user_id: string; - file_count: number; - total_size: number; - total_cost: number; - updated_at: string; + id?: (string | null); + user_id: (string | null); + file_count?: (number | null); + total_size?: (number | null); + total_cost?: (number | null); + updated_at?: (string | null); total_transactions?: (number | null); total_pages?: (number | null); - balance: number; + balance?: number; }; export type UserUpdate = { @@ -330,13 +361,24 @@ export type StoragesGetMyStorageStatResponse = (UserStorageStatPublic); export type TopupGetTopupPackagesResponse = (TopupPackagesResponse); -export type TopupCreateTopupPaymentData = { +export type TopupCreatePaymentData = { requestBody: CreatePaymentRequest; }; -export type TopupCreateTopupPaymentResponse = (CreatePaymentResponse); +export type TopupCreatePaymentResponse = (CreatePaymentResponse); + +export type TopupTopupReturnResponse = (PaymentReturnResponse); + +export type TopupGetMyBalanceResponse = (UserBalancePublic); + +export type TopupGetMyTransactionsData = { + limit?: number; + skip?: number; +}; + +export type TopupGetMyTransactionsResponse = (Array); -export type TopupTopupReturnResponse = (unknown); +export type TopupTopupIpnResponse = (unknown); export type UsersReadUsersData = { limit?: number; diff --git a/frontend/src/components/Files/columns.tsx b/frontend/src/components/Files/columns.tsx index be8673b69e..9441a1e780 100644 --- a/frontend/src/components/Files/columns.tsx +++ b/frontend/src/components/Files/columns.tsx @@ -44,7 +44,7 @@ function DownloadMenu({ file }: { file: FileWithJobPublic }) { Excel (.xlsx) handleSelect("xlsx-acc-code")}> - Analyze Account Code then Excel (.xlsx) + Included Accounting Code (.xlsx) handleSelect("csv")}> CSV (.csv) diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 35fdbce9f7..6b10fbfaa9 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -15,7 +15,7 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/dashboard" }, { icon: Files, title: "Files", path: "/files" }, - { icon: Wallet, title: "Top Up", path: "/topup" }, + { icon: Wallet, title: "Payment", path: "/topup" }, { icon: Key, title: "API Keys", path: "/api-keys" }, ] diff --git a/frontend/src/routes/_layout/topup.tsx b/frontend/src/routes/_layout/topup.tsx index ddce712c25..78ad411ee4 100644 --- a/frontend/src/routes/_layout/topup.tsx +++ b/frontend/src/routes/_layout/topup.tsx @@ -1,23 +1,43 @@ -import { useMutation, useSuspenseQuery } from "@tanstack/react-query" +import { useMutation, useQuery, useSuspenseQuery } from "@tanstack/react-query" import { createFileRoute } from "@tanstack/react-router" -import { CheckCircle, Wallet } from "lucide-react" +import { + ArrowDownCircle, + ArrowUpCircle, + CheckCircle, + Clock, + RefreshCw, + Wallet, + XCircle, +} from "lucide-react" import { Suspense, useState } from "react" import { type TopupPackage, TopupService } from "@/client" +import type { TopupTransactionPublic } from "@/client" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" export const Route = createFileRoute("/_layout/topup")({ - component: TopupPage, + component: PaymentPage, head: () => ({ - meta: [{ title: "Top Up - FastAPI Template" }], + meta: [{ title: "Payment - FastAPI Template" }], }), }) +// ─── Formatters ───────────────────────────────────────────────────────────── + function formatVND(amount: number): string { return new Intl.NumberFormat("vi-VN", { style: "currency", @@ -26,6 +46,70 @@ function formatVND(amount: number): string { }).format(amount) } +function formatDate(dateStr: string): string { + return new Intl.DateTimeFormat("vi-VN", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(dateStr)) +} + +// ─── Balance Card ──────────────────────────────────────────────────────────── + +function BalanceCard() { + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ["myBalance"], + queryFn: () => TopupService.getMyBalance(), + }) + + return ( + + +
    +
    +

    + Available Balance +

    + {isLoading ? ( + + ) : ( +

    + {formatVND(data?.balance ?? 0)} +

    + )} + {data?.updated_at && ( +

    + Updated {formatDate(data.updated_at)} +

    + )} +
    +
    +
    + +
    + +
    +
    +
    +
    + ) +} + +// ─── Package Grid ───────────────────────────────────────────────────────────── + function PackageGrid({ selected, onSelect, @@ -41,7 +125,7 @@ function PackageGrid({ const packages = data.packages ?? [] return ( -
    +
    {packages.map((pkg) => { const isSelected = selected?.id === pkg.id return ( @@ -77,6 +161,8 @@ function PackageGridSkeleton() { ) } +// ─── QR Code Display ───────────────────────────────────────────────────────── + function QRCodeDisplay({ url }: { url: string }) { const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(url)}` return ( @@ -101,18 +187,20 @@ function QRCodeDisplay({ url }: { url: string }) { ) } -function TopupContent() { +// ─── Top-up Section ─────────────────────────────────────────────────────────── + +function TopupSection({ onSuccess }: { onSuccess: () => void }) { const [selected, setSelected] = useState(null) const [paymentUrl, setPaymentUrl] = useState(null) const { showSuccessToast } = useCustomToast() const mutation = useMutation({ mutationFn: (amount: number) => - TopupService.createTopupPayment({ requestBody: { amount } }), + TopupService.createPayment({ requestBody: { amount } }), onSuccess: (data) => { setPaymentUrl(data.payment_url) - console.log("Payment URL:", data.payment_url) showSuccessToast("QR code generated! Scan to pay.") + onSuccess() }, onError: handleError, }) @@ -129,13 +217,12 @@ function TopupContent() { } return ( -
    - {/* Package selection */} +
    - - - Select a top-up package + + + Select a top-up amount @@ -162,11 +249,10 @@ function TopupContent() { - {/* QR Code result */} {paymentUrl && ( - Scan to pay + Scan to pay @@ -177,16 +263,169 @@ function TopupContent() { ) } -function TopupPage() { +// ─── Transaction Status Badge ───────────────────────────────────────────────── + +function StatusBadge({ status }: { status: TopupTransactionPublic["status"] }) { + if (status === "success") { + return ( + + + Success + + ) + } + if (status === "failed") { + return ( + + + Failed + + ) + } + return ( + + + Pending + + ) +} + +// ─── Transaction History ────────────────────────────────────────────────────── + +function TransactionHistory({ refreshKey }: { refreshKey: number }) { + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ["myTransactions", refreshKey], + queryFn: () => TopupService.getMyTransactions({ limit: 50 }), + }) + + const transactions = data ?? [] + return ( -
    + + +
    + + + Transaction History + + +
    +
    + + {isLoading ? ( +
    + {Array.from({ length: 5 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton list + + ))} +
    + ) : transactions.length === 0 ? ( +
    + +

    No transactions yet

    +

    + Your payment history will appear here. +

    +
    + ) : ( +
    + + + + Date + Reference + Type + Amount + Status + + + + {transactions.map((txn) => ( + + + {formatDate(txn.created_at)} + + + {txn.txn_ref ?? "—"} + + + {txn.type === "credit" ? ( + + + Credit + + ) : ( + + + Debit + + )} + + + {txn.type === "credit" ? "+" : "-"} + {formatVND(txn.amount)} + + + + + + ))} + +
    +
    + )} +
    +
    + ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +function PaymentPage() { + const [historyKey, setHistoryKey] = useState(0) + + return ( +
    -

    Top Up

    +

    Payment

    - Add balance to your account via VNPAY + Manage your balance and view payment history

    - + + + + + +
    + setHistoryKey((k) => k + 1)} /> + +
    ) } diff --git a/test.py b/test.py index ad24e93198..5fd6228eca 100644 --- a/test.py +++ b/test.py @@ -1,9 +1,63 @@ -from google import genai +# Please make sure the requests library is installed +# pip install requests +import base64 +import os +import requests -# The client gets the API key from the environment variable `GEMINI_API_KEY`. -client = genai.Client() +API_URL = "https://nas3fbh253sfifna.aistudio-app.com/layout-parsing" +TOKEN = "24f39b195ccd25b584dd4d3edac1179d1688b1c3" -response = client.models.generate_content( - model="gemini-3-flash-preview", contents="Explain how AI works in a few words" -) -print(response.text) \ No newline at end of file +file_path = "test_file.pdf" + +with open(file_path, "rb") as file: + file_bytes = file.read() + file_data = base64.b64encode(file_bytes).decode("ascii") + +headers = { + "Authorization": f"token {TOKEN}", + "Content-Type": "application/json" +} + +required_payload = { + "file": file_data, + "fileType": 0, # For PDF documents, set `fileType` to 0; for images, set `fileType` to 1 +} + +optional_payload = { + "useDocOrientationClassify": False, + "useDocUnwarping": False, + "useChartRecognition": False, +} + +payload = {**required_payload, **optional_payload} +print("Sending request to layout parsing API...") +response = requests.post(API_URL, json=payload, headers=headers) +print(response.status_code) +assert response.status_code == 200 +result = response.json()["result"] + +output_dir = "output" +os.makedirs(output_dir, exist_ok=True) + +for i, res in enumerate(result["layoutParsingResults"]): + md_filename = os.path.join(output_dir, f"doc_{i}.md") + with open(md_filename, "w", encoding="utf-8") as md_file: + md_file.write(res["markdown"]["text"]) + print(f"Markdown document saved at {md_filename}") + for img_path, img in res["markdown"]["images"].items(): + full_img_path = os.path.join(output_dir, img_path) + os.makedirs(os.path.dirname(full_img_path), exist_ok=True) + img_bytes = requests.get(img).content + with open(full_img_path, "wb") as img_file: + img_file.write(img_bytes) + print(f"Image saved to: {full_img_path}") + for img_name, img in res["outputImages"].items(): + img_response = requests.get(img) + if img_response.status_code == 200: + # Save image to local + filename = os.path.join(output_dir, f"{img_name}_{i}.jpg") + with open(filename, "wb") as f: + f.write(img_response.content) + print(f"Image saved to: {filename}") + else: + print(f"Failed to download image, status code: {img_response.status_code}") \ No newline at end of file diff --git a/test_async.py b/test_async.py new file mode 100644 index 0000000000..65de60e64e --- /dev/null +++ b/test_async.py @@ -0,0 +1,122 @@ +# Please make sure the requests library is installed +# pip install requests +import json +import os +import requests +import sys +import time + +JOB_URL = "https://paddleocr.aistudio-app.com/api/v2/ocr/jobs" +TOKEN = "24f39b195ccd25b584dd4d3edac1179d1688b1c3" +MODEL = "PaddleOCR-VL-1.5" + +file_path = "test_file.pdf" + +headers = { + "Authorization": f"bearer {TOKEN}", +} + +optional_payload = { + "useDocOrientationClassify": False, + "useDocUnwarping": False, + "useChartRecognition": False, +} + +print(f"Processing file: {file_path}") + +if file_path.startswith("http"): + # URL Mode + headers["Content-Type"] = "application/json" + payload = { + "fileUrl": file_path, + "model": MODEL, + "optionalPayload": optional_payload + } + job_response = requests.post(JOB_URL, json=payload, headers=headers) +else: + # Local File Mode + if not os.path.exists(file_path): + print(f"Error: File not found at {file_path}") + sys.exit(1) + + data = { + "model": MODEL, + "optionalPayload": json.dumps(optional_payload) + } + + with open(file_path, "rb") as f: + files = {"file": f} + job_response = requests.post(JOB_URL, headers=headers, data=data, files=files) + +print(f"Response status: {job_response.status_code}") +if job_response.status_code != 200: + print(f"Response content: {job_response.text}") + +assert job_response.status_code == 200 +jobId = job_response.json()["data"]["jobId"] +print(f"Job submitted successfully. job id: {jobId}") +print("Start polling for results") + +jsonl_url = "" +while True: + job_result_response = requests.get(f"{JOB_URL}/{jobId}", headers=headers) + assert job_result_response.status_code == 200 + state = job_result_response.json()["data"]["state"] + if state == 'pending': + print("The current status of the job is pending") + elif state == 'running': + try: + total_pages = job_result_response.json()['data']['extractProgress']['totalPages'] + extracted_pages = job_result_response.json()['data']['extractProgress']['extractedPages'] + print(f"The current status of the job is running, total pages: {total_pages}, extracted pages: {extracted_pages}") + except KeyError: + print("The current status of the job is running...") + elif state == 'done': + extracted_pages = job_result_response.json()['data']['extractProgress']['extractedPages'] + start_time = job_result_response.json()['data']['extractProgress']['startTime'] + end_time = job_result_response.json()['data']['extractProgress']['endTime'] + print(f"Job completed, successfully extracted pages: {extracted_pages}, start time: {start_time}, end time: {end_time}") + jsonl_url = job_result_response.json()['data']['resultUrl']['jsonUrl'] + break + elif state == "failed": + error_msg = job_result_response.json()['data']['errorMsg'] + print(f"Job failed, failure reason:{error_msg}") + sys.exit() + + time.sleep(5) + +if jsonl_url: + jsonl_response = requests.get(jsonl_url) + jsonl_response.raise_for_status() + lines = jsonl_response.text.strip().split('\n') + output_dir = "output" + os.makedirs(output_dir, exist_ok=True) + page_num = 0 + for line_num, line in enumerate(lines, start=1): + line = line.strip() + if not line: + continue + result = json.loads(line)["result"] + for i, res in enumerate(result["layoutParsingResults"]): + md_filename = os.path.join(output_dir, f"doc_{page_num}.md") + with open(md_filename, "w", encoding="utf-8") as md_file: + md_file.write(res["markdown"]["text"]) + print(f"Markdown document saved at {md_filename}") + for img_path, img in res["markdown"]["images"].items(): + full_img_path = os.path.join(output_dir, img_path) + os.makedirs(os.path.dirname(full_img_path), exist_ok=True) + img_bytes = requests.get(img).content + with open(full_img_path, "wb") as img_file: + img_file.write(img_bytes) + print(f"Image saved to: {full_img_path}") + for img_name, img in res["outputImages"].items(): + img_response = requests.get(img) + if img_response.status_code == 200: + # Save image to local + filename = os.path.join(output_dir, f"{img_name}_{page_num}.jpg") + with open(filename, "wb") as f: + f.write(img_response.content) + print(f"Image saved to: {filename}") + else: + print(f"Failed to download image, status code: {img_response.status_code}") + page_num += 1 \ No newline at end of file diff --git a/test_file.pdf b/test_file.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0c2b982c1e4ebce1dcdc4d04f246c2d5ea72a71c GIT binary patch literal 789875 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�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�org5@AS)lbjc1M$Y$K|YbLf}rX{5GXh_EvY` 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 literal 0 HcmV?d00001 From 8974e44f1c89cfdb5555b2546100c9548fa9a738 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Wed, 10 Jun 2026 09:35:40 +0700 Subject: [PATCH 23/30] init --- .DS_Store | Bin 8196 -> 10244 bytes front-end/.gitignore | 10 + front-end/CLAUDE.md | 459 +++++++++++ front-end/app/[locale]/(app)/billing/page.tsx | 9 + .../app/[locale]/(app)/companies/page.tsx | 9 + .../app/[locale]/(app)/dashboard/page.tsx | 7 + .../app/[locale]/(app)/documents/page.tsx | 9 + front-end/app/[locale]/(app)/layout.tsx | 29 + front-end/app/[locale]/(app)/members/page.tsx | 9 + front-end/app/[locale]/(app)/profile/page.tsx | 9 + .../app/[locale]/(app)/settings/page.tsx | 7 + front-end/app/[locale]/(app)/upload/page.tsx | 9 + front-end/app/[locale]/(app)/users/page.tsx | 9 + front-end/app/[locale]/layout.tsx | 44 + front-end/app/[locale]/page.tsx | 25 + front-end/app/fonts.ts | 25 + front-end/app/globals.css | 751 ++++++++++++++++++ front-end/app/layout.tsx | 7 + front-end/bun.lock | 369 +++++++++ .../components/dashboard/ActivityFeed.tsx | 41 + .../components/dashboard/BillingView.tsx | 102 +++ .../components/dashboard/CompaniesView.tsx | 61 ++ .../components/dashboard/DocumentsView.tsx | 146 ++++ .../components/dashboard/HistoryView.tsx | 49 ++ .../components/dashboard/MembersView.tsx | 58 ++ .../components/dashboard/OverviewView.tsx | 46 ++ front-end/components/dashboard/ParseChart.tsx | 90 +++ .../components/dashboard/ProfileView.tsx | 70 ++ .../components/dashboard/SettingsView.tsx | 122 +++ front-end/components/dashboard/StatCards.tsx | 55 ++ front-end/components/dashboard/UploadView.tsx | 234 ++++++ front-end/components/dashboard/hooks.ts | 28 + front-end/components/layouts/AdminShell.tsx | 89 +++ front-end/components/layouts/CompanyShell.tsx | 29 + front-end/components/layouts/OrgSwitcher.tsx | 51 ++ front-end/components/layouts/SidebarShell.tsx | 143 ++++ front-end/components/layouts/UserShell.tsx | 23 + front-end/components/sections/Background.tsx | 30 + front-end/components/sections/Features.tsx | 67 ++ front-end/components/sections/FinalCTA.tsx | 31 + front-end/components/sections/Footer.tsx | 48 ++ front-end/components/sections/Hero.tsx | 140 ++++ front-end/components/sections/HowItWorks.tsx | 52 ++ front-end/components/sections/Nav.tsx | 40 + front-end/components/sections/Reveal.tsx | 28 + front-end/components/sections/Stats.tsx | 81 ++ front-end/components/ui/Badge.tsx | 31 + front-end/components/ui/Button.tsx | 25 + front-end/components/ui/LocaleSwitcher.tsx | 70 ++ front-end/components/ui/Pill.tsx | 18 + front-end/components/ui/Providers.tsx | 21 + front-end/components/ui/RoleSwitcher.tsx | 105 +++ front-end/components/ui/ThemeToggle.tsx | 29 + front-end/components/ui/Toggle.tsx | 22 + front-end/lib/actions.ts | 23 + front-end/lib/auth.ts | 71 ++ front-end/lib/data.ts | 81 ++ front-end/lib/i18n.ts | 12 + front-end/lib/navigation.ts | 5 + front-end/lib/request.ts | 12 + front-end/lib/utils.ts | 6 + front-end/messages/en.json | 300 +++++++ front-end/messages/vi.json | 300 +++++++ front-end/middleware.ts | 13 + front-end/next.config.mjs | 10 + front-end/package.json | 33 + front-end/postcss.config.js | 6 + front-end/tailwind.config.ts | 67 ++ front-end/tsconfig.json | 23 + .../{ => New Folder With Items}/Dockerfile | 0 .../Dockerfile.playwright | 0 .../{ => New Folder With Items}/README.md | 0 .../{ => New Folder With Items}/biome.json | 0 .../components.json | 0 .../{ => New Folder With Items}/index.html | 0 .../nginx-backend-not-found.conf | 0 .../{ => New Folder With Items}/nginx.conf | 0 .../openapi-ts.config.ts | 0 .../{ => New Folder With Items}/package.json | 0 .../playwright.config.ts | 0 .../assets/images/fastapi-icon-light.svg | 0 .../public/assets/images/fastapi-icon.svg | 0 .../assets/images/fastapi-logo-light.svg | 0 .../public/assets/images/fastapi-logo.svg | 0 .../public/assets/images/favicon.png | Bin .../public/assets/images/logo_dark.png | Bin .../public/assets/images/logo_light.png | Bin .../src/client/core/ApiError.ts | 0 .../src/client/core/ApiRequestOptions.ts | 0 .../src/client/core/ApiResult.ts | 0 .../src/client/core/CancelablePromise.ts | 0 .../src/client/core/OpenAPI.ts | 0 .../src/client/core/request.ts | 0 .../src/client/index.ts | 0 .../src/client/schemas.gen.ts | 0 .../src/client/sdk.gen.ts | 0 .../src/client/types.gen.ts | 0 .../src/components/Admin/AddUser.tsx | 0 .../src/components/Admin/DeleteUser.tsx | 0 .../src/components/Admin/EditUser.tsx | 0 .../src/components/Admin/UserActionsMenu.tsx | 0 .../src/components/Admin/columns.tsx | 0 .../src/components/Auth/AuthModal.tsx | 0 .../src/components/Auth/LoginModal.tsx | 0 .../src/components/Auth/SignupModal.tsx | 0 .../src/components/BankTypeSelect.tsx | 0 .../src/components/Common/Appearance.tsx | 0 .../src/components/Common/AuthLayout.tsx | 0 .../src/components/Common/DataTable.tsx | 0 .../src/components/Common/ErrorComponent.tsx | 0 .../src/components/Common/Footer.tsx | 0 .../components/Common/LanguageSwitcher.tsx | 0 .../src/components/Common/Logo.tsx | 0 .../src/components/Common/NotFound.tsx | 0 .../src/components/FileHistoryTable.tsx | 0 .../src/components/FileUploadDropzone.tsx | 0 .../src/components/FileUploadZone.tsx | 0 .../src/components/Files/FilePreviewModal.tsx | 0 .../src/components/Files/columns.tsx | 0 .../src/components/Items/AddItem.tsx | 0 .../src/components/Items/DeleteItem.tsx | 0 .../src/components/Items/EditItem.tsx | 0 .../src/components/Items/ItemActionsMenu.tsx | 0 .../src/components/Pending/PendingItems.tsx | 0 .../src/components/Pending/PendingUsers.tsx | 0 .../src/components/PricingCard.tsx | 0 .../src/components/Sidebar/AppSidebar.tsx | 0 .../src/components/Sidebar/Main.tsx | 0 .../src/components/Sidebar/User.tsx | 0 .../src/components/StatusBadge.tsx | 0 .../UserSettings/ChangePassword.tsx | 0 .../components/UserSettings/DeleteAccount.tsx | 0 .../UserSettings/DeleteConfirmation.tsx | 0 .../components/UserSettings/MonthlyUsage.tsx | 0 .../UserSettings/PaymentMethods.tsx | 0 .../UserSettings/SubscriptionPlan.tsx | 0 .../UserSettings/UserInformation.tsx | 0 .../src/components/faq.tsx | 0 .../src/components/features.tsx | 0 .../src/components/footer.tsx | 0 .../src/components/header.tsx | 0 .../src/components/hero.tsx | 0 .../src/components/how-it-works.tsx | 0 .../components/loading-spinner-provider.tsx | 0 .../src/components/testimonials.tsx | 0 .../src/components/theme-provider.tsx | 0 .../src/components/ui/alert.tsx | 0 .../src/components/ui/avatar.tsx | 0 .../src/components/ui/badge.tsx | 0 .../src/components/ui/button-group.tsx | 0 .../src/components/ui/button.tsx | 0 .../src/components/ui/card.tsx | 0 .../src/components/ui/checkbox.tsx | 0 .../src/components/ui/dialog.tsx | 0 .../src/components/ui/dropdown-menu.tsx | 0 .../src/components/ui/empty.tsx | 0 .../src/components/ui/form.tsx | 0 .../src/components/ui/input.tsx | 0 .../src/components/ui/label.tsx | 0 .../src/components/ui/loading-button.tsx | 0 .../src/components/ui/pagination.tsx | 0 .../src/components/ui/password-input.tsx | 0 .../src/components/ui/progress.tsx | 0 .../src/components/ui/select.tsx | 0 .../src/components/ui/separator.tsx | 0 .../src/components/ui/sheet.tsx | 0 .../src/components/ui/sidebar.tsx | 0 .../src/components/ui/skeleton.tsx | 0 .../src/components/ui/sonner.tsx | 0 .../src/components/ui/spinner.tsx | 0 .../src/components/ui/table.tsx | 0 .../src/components/ui/tabs.tsx | 0 .../src/components/ui/tooltip.tsx | 0 .../src/hooks/useAuth.ts | 0 .../src/hooks/useCopyToClipboard.ts | 0 .../src/hooks/useCustomToast.ts | 0 .../src/hooks/useDownloadFile.ts | 0 .../src/hooks/useMobile.ts | 0 .../src/i18n/index.ts | 0 .../src/i18n/locales/en/common.json | 0 .../src/i18n/locales/vi/common.json | 0 .../{ => New Folder With Items}/src/index.css | 0 .../src/lib/fetchWithAuth.ts | 0 .../src/lib/mock-data.ts | 0 .../src/lib/utils.ts | 0 .../{ => New Folder With Items}/src/main.tsx | 0 .../src/routeTree.gen.ts | 0 .../src/routes/__root.tsx | 0 .../src/routes/_layout.tsx | 0 .../src/routes/_layout/admin.tsx | 0 .../src/routes/_layout/api-keys.tsx | 0 .../src/routes/_layout/dashboard.tsx | 0 .../src/routes/_layout/files.tsx | 0 .../src/routes/_layout/items.tsx | 0 .../src/routes/_layout/payment/return.tsx | 0 .../src/routes/_layout/settings.tsx | 0 .../src/routes/_layout/topup.tsx | 0 .../src/routes/_public.tsx | 0 .../src/routes/_public/index.tsx | 0 .../src/routes/_public/pricing.tsx | 0 .../src/routes/login.tsx | 0 .../src/routes/recover-password.tsx | 0 .../src/routes/reset-password.tsx | 0 .../src/routes/signup.tsx | 0 .../{ => New Folder With Items}/src/utils.ts | 0 .../src/vite-env.d.ts | 0 .../tests/admin.spec.ts | 0 .../tests/auth.setup.ts | 0 .../tests/config.ts | 0 .../tests/items.spec.ts | 0 .../tests/login.spec.ts | 0 .../tests/reset-password.spec.ts | 0 .../tests/sign-up.spec.ts | 0 .../tests/user-settings.spec.ts | 0 .../tests/utils/mailcatcher.ts | 0 .../tests/utils/privateApi.ts | 0 .../tests/utils/random.ts | 0 .../tests/utils/user.ts | 0 .../tsconfig.build.json | 0 .../{ => New Folder With Items}/tsconfig.json | 0 .../tsconfig.node.json | 0 .../vite.config.ts | 0 222 files changed, 5033 insertions(+) create mode 100644 front-end/.gitignore create mode 100644 front-end/CLAUDE.md create mode 100644 front-end/app/[locale]/(app)/billing/page.tsx create mode 100644 front-end/app/[locale]/(app)/companies/page.tsx create mode 100644 front-end/app/[locale]/(app)/dashboard/page.tsx create mode 100644 front-end/app/[locale]/(app)/documents/page.tsx create mode 100644 front-end/app/[locale]/(app)/layout.tsx create mode 100644 front-end/app/[locale]/(app)/members/page.tsx create mode 100644 front-end/app/[locale]/(app)/profile/page.tsx create mode 100644 front-end/app/[locale]/(app)/settings/page.tsx create mode 100644 front-end/app/[locale]/(app)/upload/page.tsx create mode 100644 front-end/app/[locale]/(app)/users/page.tsx create mode 100644 front-end/app/[locale]/layout.tsx create mode 100644 front-end/app/[locale]/page.tsx create mode 100644 front-end/app/fonts.ts create mode 100644 front-end/app/globals.css create mode 100644 front-end/app/layout.tsx create mode 100644 front-end/bun.lock create mode 100644 front-end/components/dashboard/ActivityFeed.tsx create mode 100644 front-end/components/dashboard/BillingView.tsx create mode 100644 front-end/components/dashboard/CompaniesView.tsx create mode 100644 front-end/components/dashboard/DocumentsView.tsx create mode 100644 front-end/components/dashboard/HistoryView.tsx create mode 100644 front-end/components/dashboard/MembersView.tsx create mode 100644 front-end/components/dashboard/OverviewView.tsx create mode 100644 front-end/components/dashboard/ParseChart.tsx create mode 100644 front-end/components/dashboard/ProfileView.tsx create mode 100644 front-end/components/dashboard/SettingsView.tsx create mode 100644 front-end/components/dashboard/StatCards.tsx create mode 100644 front-end/components/dashboard/UploadView.tsx create mode 100644 front-end/components/dashboard/hooks.ts create mode 100644 front-end/components/layouts/AdminShell.tsx create mode 100644 front-end/components/layouts/CompanyShell.tsx create mode 100644 front-end/components/layouts/OrgSwitcher.tsx create mode 100644 front-end/components/layouts/SidebarShell.tsx create mode 100644 front-end/components/layouts/UserShell.tsx create mode 100644 front-end/components/sections/Background.tsx create mode 100644 front-end/components/sections/Features.tsx create mode 100644 front-end/components/sections/FinalCTA.tsx create mode 100644 front-end/components/sections/Footer.tsx create mode 100644 front-end/components/sections/Hero.tsx create mode 100644 front-end/components/sections/HowItWorks.tsx create mode 100644 front-end/components/sections/Nav.tsx create mode 100644 front-end/components/sections/Reveal.tsx create mode 100644 front-end/components/sections/Stats.tsx create mode 100644 front-end/components/ui/Badge.tsx create mode 100644 front-end/components/ui/Button.tsx create mode 100644 front-end/components/ui/LocaleSwitcher.tsx create mode 100644 front-end/components/ui/Pill.tsx create mode 100644 front-end/components/ui/Providers.tsx create mode 100644 front-end/components/ui/RoleSwitcher.tsx create mode 100644 front-end/components/ui/ThemeToggle.tsx create mode 100644 front-end/components/ui/Toggle.tsx create mode 100644 front-end/lib/actions.ts create mode 100644 front-end/lib/auth.ts create mode 100644 front-end/lib/data.ts create mode 100644 front-end/lib/i18n.ts create mode 100644 front-end/lib/navigation.ts create mode 100644 front-end/lib/request.ts create mode 100644 front-end/lib/utils.ts create mode 100644 front-end/messages/en.json create mode 100644 front-end/messages/vi.json create mode 100644 front-end/middleware.ts create mode 100644 front-end/next.config.mjs create mode 100644 front-end/package.json create mode 100644 front-end/postcss.config.js create mode 100644 front-end/tailwind.config.ts create mode 100644 front-end/tsconfig.json rename frontend/{ => New Folder With Items}/Dockerfile (100%) rename frontend/{ => New Folder With Items}/Dockerfile.playwright (100%) rename frontend/{ => New Folder With Items}/README.md (100%) rename frontend/{ => New Folder With Items}/biome.json (100%) rename frontend/{ => New Folder With Items}/components.json (100%) rename frontend/{ => New Folder With Items}/index.html (100%) rename frontend/{ => New Folder With Items}/nginx-backend-not-found.conf (100%) rename frontend/{ => New Folder With Items}/nginx.conf (100%) rename frontend/{ => New Folder With Items}/openapi-ts.config.ts (100%) rename frontend/{ => New Folder With Items}/package.json (100%) rename frontend/{ => New Folder With Items}/playwright.config.ts (100%) rename frontend/{ => New Folder With Items}/public/assets/images/fastapi-icon-light.svg (100%) rename frontend/{ => New Folder With Items}/public/assets/images/fastapi-icon.svg (100%) rename frontend/{ => New Folder With Items}/public/assets/images/fastapi-logo-light.svg (100%) rename frontend/{ => New Folder With Items}/public/assets/images/fastapi-logo.svg (100%) rename frontend/{ => New Folder With Items}/public/assets/images/favicon.png (100%) rename frontend/{ => New Folder With Items}/public/assets/images/logo_dark.png (100%) rename frontend/{ => New Folder With Items}/public/assets/images/logo_light.png (100%) rename frontend/{ => New Folder With Items}/src/client/core/ApiError.ts (100%) rename frontend/{ => New Folder With Items}/src/client/core/ApiRequestOptions.ts (100%) rename frontend/{ => New Folder With Items}/src/client/core/ApiResult.ts (100%) rename frontend/{ => New Folder With Items}/src/client/core/CancelablePromise.ts (100%) rename frontend/{ => New Folder With Items}/src/client/core/OpenAPI.ts (100%) rename frontend/{ => New Folder With Items}/src/client/core/request.ts (100%) rename frontend/{ => New Folder With Items}/src/client/index.ts (100%) rename frontend/{ => New Folder With Items}/src/client/schemas.gen.ts (100%) rename frontend/{ => New Folder With Items}/src/client/sdk.gen.ts (100%) rename frontend/{ => New Folder With Items}/src/client/types.gen.ts (100%) rename frontend/{ => New Folder With Items}/src/components/Admin/AddUser.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Admin/DeleteUser.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Admin/EditUser.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Admin/UserActionsMenu.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Admin/columns.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Auth/AuthModal.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Auth/LoginModal.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Auth/SignupModal.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/BankTypeSelect.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/Appearance.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/AuthLayout.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/DataTable.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/ErrorComponent.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/Footer.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/LanguageSwitcher.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/Logo.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Common/NotFound.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/FileHistoryTable.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/FileUploadDropzone.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/FileUploadZone.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Files/FilePreviewModal.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Files/columns.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Items/AddItem.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Items/DeleteItem.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Items/EditItem.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Items/ItemActionsMenu.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Pending/PendingItems.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Pending/PendingUsers.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/PricingCard.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Sidebar/AppSidebar.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Sidebar/Main.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/Sidebar/User.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/StatusBadge.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/ChangePassword.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/DeleteAccount.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/DeleteConfirmation.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/MonthlyUsage.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/PaymentMethods.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/SubscriptionPlan.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/UserSettings/UserInformation.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/faq.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/features.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/footer.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/header.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/hero.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/how-it-works.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/loading-spinner-provider.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/testimonials.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/theme-provider.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/alert.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/avatar.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/badge.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/button-group.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/button.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/card.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/checkbox.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/dialog.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/dropdown-menu.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/empty.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/form.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/input.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/label.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/loading-button.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/pagination.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/password-input.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/progress.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/select.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/separator.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/sheet.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/sidebar.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/skeleton.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/sonner.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/spinner.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/table.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/tabs.tsx (100%) rename frontend/{ => New Folder With Items}/src/components/ui/tooltip.tsx (100%) rename frontend/{ => New Folder With Items}/src/hooks/useAuth.ts (100%) rename frontend/{ => New Folder With Items}/src/hooks/useCopyToClipboard.ts (100%) rename frontend/{ => New Folder With Items}/src/hooks/useCustomToast.ts (100%) rename frontend/{ => New Folder With Items}/src/hooks/useDownloadFile.ts (100%) rename frontend/{ => New Folder With Items}/src/hooks/useMobile.ts (100%) rename frontend/{ => New Folder With Items}/src/i18n/index.ts (100%) rename frontend/{ => New Folder With Items}/src/i18n/locales/en/common.json (100%) rename frontend/{ => New Folder With Items}/src/i18n/locales/vi/common.json (100%) rename frontend/{ => New Folder With Items}/src/index.css (100%) rename frontend/{ => New Folder With Items}/src/lib/fetchWithAuth.ts (100%) rename frontend/{ => New Folder With Items}/src/lib/mock-data.ts (100%) rename frontend/{ => New Folder With Items}/src/lib/utils.ts (100%) rename frontend/{ => New Folder With Items}/src/main.tsx (100%) rename frontend/{ => New Folder With Items}/src/routeTree.gen.ts (100%) rename frontend/{ => New Folder With Items}/src/routes/__root.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/admin.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/api-keys.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/dashboard.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/files.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/items.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/payment/return.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/settings.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_layout/topup.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_public.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_public/index.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/_public/pricing.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/login.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/recover-password.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/reset-password.tsx (100%) rename frontend/{ => New Folder With Items}/src/routes/signup.tsx (100%) rename frontend/{ => New Folder With Items}/src/utils.ts (100%) rename frontend/{ => New Folder With Items}/src/vite-env.d.ts (100%) rename frontend/{ => New Folder With Items}/tests/admin.spec.ts (100%) rename frontend/{ => New Folder With Items}/tests/auth.setup.ts (100%) rename frontend/{ => New Folder With Items}/tests/config.ts (100%) rename frontend/{ => New Folder With Items}/tests/items.spec.ts (100%) rename frontend/{ => New Folder With Items}/tests/login.spec.ts (100%) rename frontend/{ => New Folder With Items}/tests/reset-password.spec.ts (100%) rename frontend/{ => New Folder With Items}/tests/sign-up.spec.ts (100%) rename frontend/{ => New Folder With Items}/tests/user-settings.spec.ts (100%) rename frontend/{ => New Folder With Items}/tests/utils/mailcatcher.ts (100%) rename frontend/{ => New Folder With Items}/tests/utils/privateApi.ts (100%) rename frontend/{ => New Folder With Items}/tests/utils/random.ts (100%) rename frontend/{ => New Folder With Items}/tests/utils/user.ts (100%) rename frontend/{ => New Folder With Items}/tsconfig.build.json (100%) rename frontend/{ => New Folder With Items}/tsconfig.json (100%) rename frontend/{ => New Folder With Items}/tsconfig.node.json (100%) rename frontend/{ => New Folder With Items}/vite.config.ts (100%) diff --git a/.DS_Store b/.DS_Store index b74f38acde0fed7c8d45f2e30a686871af5f53a5..99e9ecae159626cfeebf485b31be237b5c75f45b 100644 GIT binary patch delta 1076 zcmZp1XbF&DU|?W$DortDU{C-uIe-{M3-C-V6q~50$f&+CU^hRb`eYt~dP^aOWQKf( zT!sQ5SWm#p1v#0; zC6l{^3fL@k6wHjxCJEcuW3eSC-7q*gKeqtv3J8HkRc^kE3)Ezed2F_3OmB`mVs~|J zehN;N4yYb1$RNh*vf!e;ocuhX_Zb)`n+P3)d#6QPNLnUdKsYHeIXk^5zceq!IX@@A z$SJ2ZHC}+fI3vH@GdVvmpeVI0Gqrs3GT~kF5t%@N;LNJj==7q@l;DEI{eBE}TbFnNWDte3Jw07O*T!5hK=CKGK2M}}aA7=}WI28QVji-GZckl`G|V}`E` z{~381#TbN2{3+(8A?#& z7o2F`A*Bd*m^xr0D`2QZRSix*$f|kas^b|Ffu>giQ&2KCoeqq$Ah#e{gse3Un6`7k zsk{J~aMRE%?FQ+b+$`R*vEdJkATuOf6u5!3D=7DDEd0(qnO`PQgb9+TG(ZX%85j&e S^yC7W-pR*>jE7+K9R>jXiwlAP delta 324 zcmZn(XmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD77(QH$S7)WFCR~&F2NyFitKM zIm~9HqhMxeGPyw5esZO#E0dJe$S?N_2?>VD@jO$x I@EKAD04P#QC;$Ke diff --git a/front-end/.gitignore b/front-end/.gitignore new file mode 100644 index 0000000000..098081a26d --- /dev/null +++ b/front-end/.gitignore @@ -0,0 +1,10 @@ +/node_modules +/.next +/out +/build +next-env.d.ts +*.tsbuildinfo +.DS_Store +.design-ref +bun.lockb +npm-debug.log* diff --git a/front-end/CLAUDE.md b/front-end/CLAUDE.md new file mode 100644 index 0000000000..018810b279 --- /dev/null +++ b/front-end/CLAUDE.md @@ -0,0 +1,459 @@ +# Claude Code — Next.js UI Clone Instructions + +> Attach this file to your Claude Code session. Then say: +> **"Follow CLAUDE.md and build the design at this URL: https://fcf9af8a-887b-48e9-8959-20fc729d1570.claudeusercontent.com/v1/design/projects/fcf9af8a-887b-48e9-8959-20fc729d1570/serve/Tabula-bundled.html?t=7a8e7a034f3adb118aebb7a2880618a5a478b71580190a030be6f207fb39cf82.16ca1d11-f9a7-478f-9b39-e5f9f99fd747.7cd9be66-50f7-4faf-874e-dc229a66e339.1781016670.fp&direct=1#how"** + +--- + +## 1. ANALYSIS PHASE + +Before writing any code: + +1. Fetch and screenshot the URL. Identify: + - All visible UI sections and their layout structure + - Color palette (extract exact hex values for both light AND dark variants if present) + - Typography: font families, sizes, weights, line-heights + - Spacing rhythm (padding, margin, gap patterns) + - Interactive elements (buttons, inputs, hover states, animations) + - Responsive breakpoints if detectable + - Any icons or illustration style +2. List every distinct component you see (e.g. Navbar, HeroSection, FeatureCard, Footer) +3. State your implementation plan before writing any code + +--- + +## 2. PROJECT STRUCTURE + +Scaffold this exact structure: + +``` +├── app/ +│ ├── layout.tsx # Root layout: fonts, metadata, theme + i18n providers +│ ├── globals.css # CSS reset + all design tokens (light + dark CSS vars) +│ │ +│ ├── [locale]/ # i18n dynamic segment (e.g. /en, /vi, /ja) +│ │ ├── layout.tsx # Locale layout: wraps with locale context +│ │ ├── page.tsx # Public marketing home page +│ │ │ +│ │ ├── (user)/ # Route group — registered users +│ │ │ ├── layout.tsx # User shell: sidebar + topbar for normal users +│ │ │ ├── dashboard/page.tsx +│ │ │ ├── profile/page.tsx +│ │ │ └── settings/page.tsx +│ │ │ +│ │ ├── (company)/ # Route group — company customers +│ │ │ ├── layout.tsx # Company shell: wider nav, team/org switcher +│ │ │ ├── dashboard/page.tsx +│ │ │ ├── members/page.tsx +│ │ │ ├── billing/page.tsx +│ │ │ └── settings/page.tsx +│ │ │ +│ │ └── (admin)/ # Route group — platform admins +│ │ ├── layout.tsx # Admin shell: full-width, dense data layout +│ │ ├── dashboard/page.tsx +│ │ ├── users/page.tsx +│ │ ├── companies/page.tsx +│ │ └── settings/page.tsx +│ +├── components/ +│ ├── ui/ # Generic reusable primitives +│ │ ├── Button.tsx +│ │ ├── Badge.tsx +│ │ ├── ThemeToggle.tsx # Dark/light mode switcher +│ │ ├── LocaleSwitcher.tsx # Language picker dropdown +│ │ └── ... # Only what the design uses +│ │ +│ ├── layouts/ # Layout shells per role +│ │ ├── UserShell.tsx # Sidebar + topbar for (user) group +│ │ ├── CompanyShell.tsx # Sidebar + org switcher for (company) group +│ │ └── AdminShell.tsx # Dense full-width shell for (admin) group +│ │ +│ └── sections/ # Public page sections (marketing site) +│ ├── Navbar.tsx +│ ├── HeroSection.tsx +│ └── ... # One file per visual section +│ +├── lib/ +│ ├── utils.ts # cn() helper (clsx + tailwind-merge) +│ ├── auth.ts # Role type definitions + mock auth helpers +│ └── i18n.ts # Locale config, supported locales list +│ +├── messages/ # i18n translation files +│ ├── en.json +│ ├── vi.json +│ └── [other locales as needed] +│ +├── middleware.ts # next-intl locale detection + redirect +├── public/ +│ └── fonts/ # Self-hosted fonts if needed +│ +├── tailwind.config.ts # Extended with design tokens, dark variant: 'class' +└── next.config.ts # Wrapped with next-intl plugin +``` + +--- + +## 3. DARK MODE + +**Implementation: `next-themes` with Tailwind class strategy** + +### Setup + +- Install `next-themes` +- In `tailwind.config.ts`: set `darkMode: 'class'` +- Wrap root layout with `` + +### CSS token strategy + +Define ALL colors as CSS custom properties in `globals.css` with both light and dark values: + +```css +:root { + --color-bg-primary: #ffffff; + --color-bg-secondary: #f8f9fa; + --color-bg-surface: #f1f3f5; + --color-text-primary: #111827; + --color-text-secondary: #6b7280; + --color-text-muted: #9ca3af;s + --color-border: #e5e7eb; + --color-accent: #6366f1; /* replace with exact value from the URL */ + --color-accent-hover: #4f46e5; +} + +.dark { + --color-bg-primary: #0f1117; + --color-bg-secondary: #1a1d27; + --color-bg-surface: #22263a; + --color-text-primary: #f9fafb; + --color-text-secondary: #9ca3af; + --color-text-muted: #6b7280; + --color-border: #2d3147; + --color-accent: #818cf8; /* lighter variant for dark bg readability */ + --color-accent-hover: #6366f1; +} +``` + +Map every token into `tailwind.config.ts`: + +```ts +colors: { + bg: { + primary: 'var(--color-bg-primary)', + secondary: 'var(--color-bg-secondary)', + surface: 'var(--color-bg-surface)', + }, + text: { + primary: 'var(--color-text-primary)', + secondary: 'var(--color-text-secondary)', + muted: 'var(--color-text-muted)', + }, + border: 'var(--color-border)', + accent: { + DEFAULT: 'var(--color-accent)', + hover: 'var(--color-accent-hover)', + }, +} +``` + +### ThemeToggle component + +- Use `useTheme()` from `next-themes` +- Show sun icon in dark mode, moon icon in light mode +- Place in every layout shell topbar (UserShell, CompanyShell, AdminShell) and public Navbar +- Preference persists to localStorage automatically via `next-themes` + +### Rules + +- NEVER use hardcoded hex values in components — always use token classes (`bg-bg-primary`, `text-text-secondary`) +- Every color must have a dark-mode counterpart via the token system +- Images: add `dark:opacity-90` or `dark:brightness-90` if they look blown out in dark mode +- Mental test before finishing: "if this background were near-black, is every text element still readable?" + +--- + +## 4. MULTI-LANGUAGE (i18n) + +**Implementation: `next-intl` with App Router** + +### Setup + +```ts +// lib/i18n.ts +export const locales = ['en', 'vi'] as const // extend as needed +export type Locale = typeof locales[number] +export const defaultLocale: Locale = 'en' +``` + +```ts +// middleware.ts +import createMiddleware from 'next-intl/middleware' +import { locales, defaultLocale } from '@/lib/i18n' + +export default createMiddleware({ + locales, + defaultLocale, + localePrefix: 'always' // URLs: /en/dashboard, /vi/dashboard +}) + +export const config = { + matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'] +} +``` + +### Translation file structure + +```json +// messages/en.json +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "loading": "Loading...", + "error": "Something went wrong" + }, + "nav": { + "dashboard": "Dashboard", + "profile": "Profile", + "settings": "Settings", + "logout": "Log out" + }, + "user": { + "dashboard": { "title": "My Dashboard", "welcome": "Welcome back, {name}" } + }, + "company": { + "dashboard": { "title": "Company Dashboard", "members": "Team Members" } + }, + "admin": { + "dashboard": { "title": "Admin Panel", "users": "All Users", "companies": "All Companies" } + }, + "public": { + "hero": { "headline": "...", "subheading": "...", "cta": "Get Started" } + } +} +``` + +Mirror identical keys in `messages/vi.json` with Vietnamese translations. + +### Usage in components + +```tsx +import { useTranslations } from 'next-intl' + +export default function HeroSection() { + const t = useTranslations('public.hero') + return

    {t('headline')}

    +} +``` + +### LocaleSwitcher component + +- Dropdown listing all supported locales with their native names (English, Tiếng Việt, etc.) +- On select: use `next-intl`'s `useRouter` to call `router.replace(pathname, { locale: newLocale })` +- Place in every layout shell topbar and on the public Navbar, next to ThemeToggle + +### Rules + +- Zero hardcoded UI strings in JSX — every visible string goes through `t()` +- Data constants (nav items, feature lists) store translation keys, not raw strings +- Date/number formatting uses `next-intl`'s `useFormatter` for locale-aware output + +--- + +## 5. ROLE-BASED LAYOUTS + +Three distinct layout shells with separate visual hierarchy and navigation scope. + +### Role definitions + +```ts +// lib/auth.ts +export type UserRole = 'user' | 'company' | 'admin' + +export interface AuthUser { + id: string + name: string + email: string + role: UserRole + companyId?: string // present only for 'company' role +} +``` + +--- + +### Layout A — UserShell (registered users) + +**Character:** Personal, friendly, focused on individual tasks. Compact sidebar. + +``` +┌─────────────────────────────────────────────────────┐ +│ Topbar: Logo | Breadcrumb ThemeToggle Lang Avatar │ +├──────────┬──────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Page Content │ +│ (240px) │ │ +│ │ │ +│ Dashboard│ │ +│ Profile │ │ +│ Settings │ │ +│ │ │ +│ [Logout] │ │ +└──────────┴──────────────────────────────────────────┘ +``` + +- Sidebar collapses to icon-only on mobile via hamburger toggle +- Avatar displays user name + role badge labeled "User" +- No org/team switcher + +--- + +### Layout B — CompanyShell (company customers) + +**Character:** Professional, team-oriented. Wider sidebar with org switcher at top. + +``` +┌─────────────────────────────────────────────────────┐ +│ Topbar: [Org Switcher ▾] ThemeToggle Lang Avatar │ +├──────────┬──────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Page Content │ +│ (260px) │ │ +│ │ │ +│ Dashboard│ │ +│ Members │ │ +│ Billing │ │ +│ Settings │ │ +│ │ │ +│ [Logout] │ │ +└──────────┴──────────────────────────────────────────┘ +``` + +- Org Switcher dropdown shows company name + logo; supports multi-org switching +- Avatar displays user name + role badge labeled "Company" +- Sidebar accent color is visually distinct from UserShell (use a separate brand token) + +--- + +### Layout C — AdminShell (platform admins) + +**Character:** Dense, data-first, full-width. Top navigation bar instead of sidebar. + +``` +┌─────────────────────────────────────────────────────┐ +│ Logo | Dashboard Users Companies Settings | ThemeToggle Lang Avatar [ADMIN] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Page Content (full width) │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +- Horizontal top nav (no sidebar) — maximises horizontal space for data tables +- Role badge "Admin" displayed in accent-red on the avatar chip +- Active nav item uses underline indicator, not background highlight +- Mobile: top nav collapses to hamburger → full-screen overlay menu + +--- + +### Route guard pattern + +Apply this to every role-group layout: + +```tsx +// app/[locale]/(user)/layout.tsx +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import UserShell from '@/components/layouts/UserShell' + +export default async function UserLayout({ + children, + params, +}: { + children: React.ReactNode + params: { locale: string } +}) { + const session = await getSession() + if (!session || session.role !== 'user') redirect(`/${params.locale}`) + return {children} +} +``` + +Repeat for `(company)` checking `role !== 'company'` and `(admin)` checking `role !== 'admin'`. + +--- + +## 6. TECHNICAL REQUIREMENTS + +**Stack:** +- Next.js 14+ with App Router +- TypeScript with `"strict": true` in `tsconfig.json` +- Tailwind CSS v3 — `darkMode: 'class'`, extended with all design tokens +- `next-themes` for dark mode persistence +- `next-intl` for i18n routing and translations +- No additional UI library unless the original design clearly uses one (shadcn/ui is acceptable if it fits) + +**Component rules:** +- Each component is a default export with a named TypeScript interface for its props +- All visible strings go through `useTranslations()` +- All colors go through CSS token classes — no hardcoded hex values in JSX +- Semantic HTML throughout: `
    `, `
    diff --git a/front-end/components/dashboard/UsersView.tsx b/front-end/components/dashboard/UsersView.tsx index f63c8eb58a..d99891dca5 100644 --- a/front-end/components/dashboard/UsersView.tsx +++ b/front-end/components/dashboard/UsersView.tsx @@ -3,9 +3,15 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { apiMessage } from "@/lib/api"; -import { UsersService, type UserPublic } from "@/lib/client"; +import { UsersService, type UserPublic, type UserType } from "@/lib/client"; import { formatDate } from "@/lib/files"; +const USER_TYPES: { value: UserType; labelKey: "roleUser" | "roleCompany" | "roleAdmin" }[] = [ + { value: "normal", labelKey: "roleUser" }, + { value: "company", labelKey: "roleCompany" }, + { value: "admin", labelKey: "roleAdmin" }, +]; + const GRADIENTS = [ "linear-gradient(135deg,oklch(0.82 0.14 205),oklch(0.82 0.14 75))", "linear-gradient(135deg,oklch(0.82 0.14 75),oklch(0.80 0.15 155))", @@ -31,6 +37,39 @@ export default function UsersView() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [newName, setNewName] = useState(""); + const [newEmail, setNewEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [newType, setNewType] = useState("company"); + const [creating, setCreating] = useState(false); + const [createMsg, setCreateMsg] = useState<{ ok: boolean; text: string } | null>(null); + + const createUser = async (e: React.FormEvent) => { + e.preventDefault(); + setCreating(true); + setCreateMsg(null); + try { + const user = await UsersService.createUserEndpoint({ + requestBody: { + email: newEmail.trim(), + password: newPassword, + full_name: newName.trim() || null, + user_type: newType, + }, + }); + setUsers((prev) => [user, ...prev]); + setCount((c) => c + 1); + setNewName(""); + setNewEmail(""); + setNewPassword(""); + setCreateMsg({ ok: true, text: t("created") }); + } catch (err) { + setCreateMsg({ ok: false, text: apiMessage(err) }); + } finally { + setCreating(false); + } + }; + useEffect(() => { let active = true; UsersService.readUsers({ limit: 100 }) @@ -48,6 +87,55 @@ export default function UsersView() { return (
    +
    + setNewName(e.target.value)} + placeholder={t("fieldName")} + aria-label={t("fieldName")} + /> + setNewEmail(e.target.value)} + placeholder={t("fieldEmail")} + aria-label={t("fieldEmail")} + /> + setNewPassword(e.target.value)} + placeholder={t("fieldPassword")} + aria-label={t("fieldPassword")} + /> + + + {createMsg && ( +
    + {createMsg.text} +
    + )} +
    {error &&
    {error}
    } @@ -82,7 +170,13 @@ export default function UsersView() { - + + @@ -145,12 +146,12 @@ export default function DocumentsView() { {loading && ( - + )} {!loading && filtered.length === 0 && ( - + )} {filtered.map((d) => ( @@ -171,6 +172,7 @@ export default function DocumentsView() { +
    {u.is_superuser ? t("roleAdmin") : t("roleUser")} + {u.user_type === "admin" || u.is_superuser + ? t("roleAdmin") + : u.user_type === "company" + ? t("roleCompany") + : t("roleUser")} + diff --git a/front-end/components/layouts/CompanyShell.tsx b/front-end/components/layouts/CompanyShell.tsx new file mode 100644 index 0000000000..3b9342d4b1 --- /dev/null +++ b/front-end/components/layouts/CompanyShell.tsx @@ -0,0 +1,32 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + FileText, + LayoutDashboard, + Settings, + UploadCloud, + User, + Users, + Wallet, +} from "lucide-react"; +import SidebarShell, { type NavItem } from "@/components/layouts/SidebarShell"; +import type { AuthUser } from "@/lib/auth"; + +const NAV: NavItem[] = [ + { key: "dashboard", href: "/dashboard", titleKey: "overview", Icon: LayoutDashboard }, + { key: "documents", href: "/documents", titleKey: "documents", Icon: FileText }, + { key: "upload", href: "/upload", titleKey: "upload", Icon: UploadCloud }, + { key: "members", href: "/members", titleKey: "members", Icon: Users }, + { key: "billing", href: "/billing", titleKey: "billing", Icon: Wallet }, + { key: "profile", href: "/profile", titleKey: "profile", Icon: User }, + { key: "settings", href: "/settings", titleKey: "settings", Icon: Settings }, +]; + +export default function CompanyShell({ user, children }: { user: AuthUser; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/front-end/components/layouts/ProtectedShell.tsx b/front-end/components/layouts/ProtectedShell.tsx index 8c6285023c..388d320568 100644 --- a/front-end/components/layouts/ProtectedShell.tsx +++ b/front-end/components/layouts/ProtectedShell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from "react"; import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import UserShell from "@/components/layouts/UserShell"; +import CompanyShell from "@/components/layouts/CompanyShell"; import AdminShell from "@/components/layouts/AdminShell"; /** @@ -21,5 +22,8 @@ export default async function ProtectedShell({ if (session.role === "admin") { return {children}; } + if (session.role === "company") { + return {children}; + } return {children}; } diff --git a/front-end/components/ui/Spinner.tsx b/front-end/components/ui/Spinner.tsx new file mode 100644 index 0000000000..b79672ddf3 --- /dev/null +++ b/front-end/components/ui/Spinner.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; + +/** Centered page-level loading indicator shown while a route segment loads. */ +export default function Spinner() { + const t = useTranslations("common"); + return ( +
    + + {t("loading")} +
    + ); +} diff --git a/front-end/lib/auth.ts b/front-end/lib/auth.ts index 308a6f984f..ad07407098 100644 --- a/front-end/lib/auth.ts +++ b/front-end/lib/auth.ts @@ -1,9 +1,10 @@ +import { cache } from "react"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { API_BASE, TOKEN_COOKIE } from "@/lib/api"; import type { UserPublic } from "@/lib/client"; -export type UserRole = "user" | "admin"; +export type UserRole = "user" | "company" | "admin"; export interface AuthUser { id: string; @@ -14,7 +15,7 @@ export interface AuthUser { plan: string; } -export const roles: UserRole[] = ["user", "admin"]; +export const roles: UserRole[] = ["user", "company", "admin"]; export function isRole(value: string | undefined): value is UserRole { return !!value && (roles as string[]).includes(value); @@ -31,24 +32,41 @@ function initialsOf(name: string): string { ); } +/** Maps the backend user_type onto the front-end role. */ +function roleOf(user: UserPublic): UserRole { + if (user.user_type === "admin" || user.is_superuser) return "admin"; + if (user.user_type === "company") return "company"; + return "user"; +} + +const PLANS: Record = { + admin: "Platform Admin", + company: "Business", + user: "Pay as you go", +}; + /** Maps the backend user onto the shape the shells/views render. */ export function toAuthUser(user: UserPublic): AuthUser { const name = user.full_name?.trim() || user.email.split("@")[0]; + const role = roleOf(user); return { id: user.id, name, email: user.email, - role: user.is_superuser ? "admin" : "user", + role, initials: initialsOf(name), - plan: user.is_superuser ? "Platform Admin" : "Pay as you go", + plan: PLANS[role], }; } /** * Resolves the session from the auth cookie by asking the API for the current * user. Returns null when signed out or the token is no longer valid. + * + * Wrapped in React cache() so the layout guard and the page guard share a + * single /users/me round-trip per request instead of fetching twice. */ -export async function getSession(): Promise { +export const getSession = cache(async (): Promise => { const cookieStore = await cookies(); const token = cookieStore.get(TOKEN_COOKIE)?.value; if (!token) return null; @@ -57,13 +75,14 @@ export async function getSession(): Promise { const res = await fetch(`${API_BASE}/api/v1/users/me`, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", + signal: AbortSignal.timeout(5000), }); if (!res.ok) return null; return toAuthUser((await res.json()) as UserPublic); } catch { return null; } -} +}); /** * Guards a role-specific page: returns the session if the current role is diff --git a/front-end/lib/client/schemas.gen.ts b/front-end/lib/client/schemas.gen.ts index a355897f73..6ad3d9cff9 100644 --- a/front-end/lib/client/schemas.gen.ts +++ b/front-end/lib/client/schemas.gen.ts @@ -804,6 +804,10 @@ export const UserCreateSchema = { type: 'boolean', title: 'Is Superuser', default: false + }, + user_type: { + '$ref': '#/components/schemas/UserType', + default: 'normal' } }, type: 'object', @@ -833,6 +837,10 @@ export const UserPublicSchema = { title: 'Is Superuser', default: false }, + user_type: { + '$ref': '#/components/schemas/UserType', + default: 'normal' + }, full_name: { anyOf: [ { @@ -998,6 +1006,12 @@ export const UserStorageStatPublicSchema = { title: 'UserStorageStatPublic' } as const; +export const UserTypeSchema = { + type: 'string', + enum: ['normal', 'company', 'admin'], + title: 'UserType' +} as const; + export const UserUpdateSchema = { properties: { email: { @@ -1059,6 +1073,16 @@ export const UserUpdateSchema = { } ], title: 'Is Superuser' + }, + user_type: { + anyOf: [ + { + '$ref': '#/components/schemas/UserType' + }, + { + type: 'null' + } + ] } }, type: 'object', diff --git a/front-end/lib/client/types.gen.ts b/front-end/lib/client/types.gen.ts index 3288dd2ff7..fba4496d3a 100644 --- a/front-end/lib/client/types.gen.ts +++ b/front-end/lib/client/types.gen.ts @@ -171,6 +171,7 @@ export type UserCreate = { full_name?: (string | null); is_active?: boolean; is_superuser?: boolean; + user_type?: UserType; }; export type UserPublic = { @@ -178,6 +179,7 @@ export type UserPublic = { email: string; is_active?: boolean; is_superuser?: boolean; + user_type?: UserType; full_name?: (string | null); created_at?: (string | null); }; @@ -205,12 +207,15 @@ export type UserStorageStatPublic = { balance?: number; }; +export type UserType = 'normal' | 'company' | 'admin'; + export type UserUpdate = { email?: (string | null); password?: (string | null); full_name?: (string | null); is_active?: (boolean | null); is_superuser?: (boolean | null); + user_type?: (UserType | null); }; export type UserUpdateMe = { diff --git a/front-end/messages/en.json b/front-end/messages/en.json index 77c9f8a95f..563063d18b 100644 --- a/front-end/messages/en.json +++ b/front-end/messages/en.json @@ -285,11 +285,18 @@ "colStatus": "Status", "colCreated": "Created", "roleAdmin": "Admin", + "roleCompany": "Company", "roleUser": "User", "active": "Active", "inactive": "Inactive", "empty": "No users found.", - "footCount": "{count} users" + "footCount": "{count} users", + "fieldName": "Full name", + "fieldEmail": "Email", + "fieldPassword": "Password", + "fieldType": "Account type", + "addUser": "Add user", + "created": "User created." }, "members": { "colMember": "Member", diff --git a/front-end/messages/vi.json b/front-end/messages/vi.json index 2a03ed7f13..0b04c03e1b 100644 --- a/front-end/messages/vi.json +++ b/front-end/messages/vi.json @@ -285,11 +285,18 @@ "colStatus": "Trạng thái", "colCreated": "Ngày tạo", "roleAdmin": "Quản trị", + "roleCompany": "Công ty", "roleUser": "Người dùng", "active": "Hoạt động", "inactive": "Vô hiệu", "empty": "Không tìm thấy người dùng.", - "footCount": "{count} người dùng" + "footCount": "{count} người dùng", + "fieldName": "Họ tên", + "fieldEmail": "Email", + "fieldPassword": "Mật khẩu", + "fieldType": "Loại tài khoản", + "addUser": "Thêm người dùng", + "created": "Đã tạo người dùng." }, "members": { "colMember": "Thành viên", From 2847399bdd08e61f6dc330a305b8ee56fae071d8 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sat, 13 Jun 2026 23:32:48 +0700 Subject: [PATCH 27/30] update --- backend/app/auth/router.py | 2 +- backend/app/core/security.py | 5 ++--- front-end/app/[locale]/(app)/layout.tsx | 1 + front-end/components/dashboard/UsersView.tsx | 8 ++------ front-end/constants/index.ts | 7 +++++++ 5 files changed, 13 insertions(+), 10 deletions(-) create mode 100644 front-end/constants/index.ts diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 9996d005b4..118c1c9ced 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -35,7 +35,7 @@ def login_access_token( access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) return Token( access_token=security.create_access_token( - user.id, expires_delta=access_token_expires + user_id=user.id, expires_delta=access_token_expires, user_type=user.user_type ) ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 1e49ebc1fe..f14a69046d 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -5,7 +5,6 @@ from pwdlib import PasswordHash from pwdlib.hashers.argon2 import Argon2Hasher from pwdlib.hashers.bcrypt import BcryptHasher - from app.core.config import settings password_hash = PasswordHash( @@ -19,9 +18,9 @@ ALGORITHM = "HS256" -def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: +def create_access_token(user_id: any, expires_delta: timedelta, user_type: str) -> str: expire = datetime.now(timezone.utc) + expires_delta - to_encode = {"exp": expire, "sub": str(subject)} + to_encode = {"exp": expire, "sub": str(user_id), "user_type": user_type} encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt diff --git a/front-end/app/[locale]/(app)/layout.tsx b/front-end/app/[locale]/(app)/layout.tsx index dd8be2e431..5fd2c1a61b 100644 --- a/front-end/app/[locale]/(app)/layout.tsx +++ b/front-end/app/[locale]/(app)/layout.tsx @@ -10,5 +10,6 @@ export default async function AppLayout({ params: { locale: string }; }) { setRequestLocale(locale); + console.log('run app layout') return {children}; } diff --git a/front-end/components/dashboard/UsersView.tsx b/front-end/components/dashboard/UsersView.tsx index d99891dca5..2ce21b9101 100644 --- a/front-end/components/dashboard/UsersView.tsx +++ b/front-end/components/dashboard/UsersView.tsx @@ -5,12 +5,7 @@ import { useTranslations } from "next-intl"; import { apiMessage } from "@/lib/api"; import { UsersService, type UserPublic, type UserType } from "@/lib/client"; import { formatDate } from "@/lib/files"; - -const USER_TYPES: { value: UserType; labelKey: "roleUser" | "roleCompany" | "roleAdmin" }[] = [ - { value: "normal", labelKey: "roleUser" }, - { value: "company", labelKey: "roleCompany" }, - { value: "admin", labelKey: "roleAdmin" }, -]; +import { USER_TYPES } from "@/constants"; const GRADIENTS = [ "linear-gradient(135deg,oklch(0.82 0.14 205),oklch(0.82 0.14 75))", @@ -37,6 +32,7 @@ export default function UsersView() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + console.log('adduser',t("addUser")) const [newName, setNewName] = useState(""); const [newEmail, setNewEmail] = useState(""); const [newPassword, setNewPassword] = useState(""); diff --git a/front-end/constants/index.ts b/front-end/constants/index.ts new file mode 100644 index 0000000000..afbb02a3b8 --- /dev/null +++ b/front-end/constants/index.ts @@ -0,0 +1,7 @@ +import { UserType } from "@/lib/client"; + +export const USER_TYPES: { value: UserType; labelKey: "roleUser" | "roleCompany" | "roleAdmin" }[] = [ + { value: "normal", labelKey: "roleUser" }, + { value: "company", labelKey: "roleCompany" }, + { value: "admin", labelKey: "roleAdmin" }, +]; \ No newline at end of file From 7b39709181315c40b64748311d3537af1e9b69a9 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 14 Jun 2026 02:20:08 +0700 Subject: [PATCH 28/30] update --- backend/app/files/router.py | 68 +++++++++-- backend/app/files/schemas.py | 20 ++++ backend/app/files/service.py | 26 +++- backend/app/ocrs/constants.py | 18 +++ backend/app/ocrs/service.py | 90 +++++++++++--- front-end/app/globals.css | 79 ++++++++++++- .../components/dashboard/DocumentsView.tsx | 37 +++++- .../components/dashboard/PreviewModal.tsx | 111 ++++++++++++++++++ front-end/components/dashboard/UploadView.tsx | 58 ++++++--- front-end/constants/ocr.ts | 19 +++ front-end/lib/client/schemas.gen.ts | 63 ++++++++++ front-end/lib/client/sdk.gen.ts | 30 ++++- front-end/lib/client/types.gen.ts | 25 +++- front-end/messages/en.json | 15 ++- front-end/messages/vi.json | 15 ++- front-end/next.config.mjs | 7 ++ 16 files changed, 617 insertions(+), 64 deletions(-) create mode 100644 front-end/components/dashboard/PreviewModal.tsx create mode 100644 front-end/constants/ocr.ts diff --git a/backend/app/files/router.py b/backend/app/files/router.py index c7f74a08dd..a5c5f6fb44 100644 --- a/backend/app/files/router.py +++ b/backend/app/files/router.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, HTTPException, Response, UploadFile +from fastapi import APIRouter, Form, HTTPException, Response, UploadFile from sqlalchemy import desc from sqlmodel import select @@ -17,6 +17,7 @@ from app.files.schemas import ( FileCreate, FileJobPublic, + FilePreviewResponse, FilePublic, FilesStatusRequest, FileWithJobPublic, @@ -24,20 +25,41 @@ from app.files.service import ( download_file, download_file_with_accounting_code, + get_preview_data +) +from app.ocrs.constants import OcrJobStatus, OcrModel +from app.ocrs.service import ( + fetch_ocr_table_pages, + get_ocr_job_status, + post_ocr_jobs, ) -from app.ocrs.service import get_ocr_job_status, get_ocr_job_status_1, post_ocr_jobs router = APIRouter(prefix="/files", tags=["files"]) +@router.get("/models", response_model=list[str]) +def list_ocr_models(): + """List the OCR models a user can choose from when parsing a document.""" + return sorted(OcrModel.ALL) + @router.post("/", response_model=FilePublic) def upload_file_endpoint( session: SessionDep, user: CurrentUser, - file: UploadFile # noqa: B008, + file: UploadFile, # noqa: B008 + model: str | None = Form(default=None), ): """ Upload a file to R2/S3 storage. + + `model` selects which PaddleOCR model parses the document. When omitted, + the configured default (settings.OCR_MODEL) is used. """ + if model is not None and model not in OcrModel.ALL: + raise HTTPException( + status_code=422, + detail=f"Unsupported model '{model}'. Allowed: {sorted(OcrModel.ALL)}", + ) + file_bytes = file.file.read() file_name = file.filename or "upload" file_type = file.content_type or "application/octet-stream" @@ -60,7 +82,7 @@ def upload_file_endpoint( raise HTTPException(status_code=500, detail="Failed to upload file to R2") logger.info(f"File {file_result.id} uploaded to R2 successfully, URL: {r2_result['PresignedURL']}") - post_ocr_jobs(session=session, file=file_result, file_url=r2_result["PresignedURL"]) + post_ocr_jobs(session=session, file=file_result, file_url=r2_result["PresignedURL"], model=model) return file_result except Exception as exc: @@ -132,6 +154,15 @@ def list_files(session: SessionDep, user: CurrentUser, skip: int = 0, limit: int result: list[FileWithJobPublic] = [] for f in files: file_job = get_file_job_by_file_id(session=session, file_id=f.id) + + # Refresh any still-processing job by polling the OCR API via its job_id. + if file_job and file_job.state in (OcrJobStatus.PENDING, OcrJobStatus.RUNNING): + try: + get_ocr_job_status(file=f, session=session, user=user) + file_job = get_file_job_by_file_id(session=session, file_id=f.id) + except Exception as exc: + logger.error(f"Error refreshing OCR status for file {f.id}: {exc}") + job_public: FileJobPublic | None = FileJobPublic.model_validate(file_job) if file_job else None result.append(FileWithJobPublic.model_validate(f, update={"job": job_public})) @@ -226,19 +257,36 @@ def get_files_batch_status( return file_jobs -@router.get("/{file_id}/result_url") -def get_file_result_url(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): +@router.get("/{file_id}/preview", response_model=FilePreviewResponse) +def preview_file_result(file_id: uuid.UUID, session: SessionDep, user: CurrentUser): """ - Get the presigned URL for the OCR result JSON file in R2 for a given file ID. + Fetch the parsed OCR result for a file from its stored ``json_url`` and + return the extracted table as JSON (``columns`` + ``rows``), ready to render + in the front end. This is the same table data the download endpoint exports. """ file = session.get(File, file_id) if not file: raise HTTPException(status_code=404, detail="File not found") if file.user_id != user.id: raise HTTPException(status_code=403, detail="Not authorized to access this file") + file_job = get_file_job_by_file_id(session=session, file_id=file_id) - if not file_job or file_job.state != "done": + if not file_job or file_job.state != OcrJobStatus.DONE: raise HTTPException(status_code=400, detail="OCR job is not done yet") + if not file_job.json_url: + raise HTTPException(status_code=400, detail="No result data available for this file") - result = get_ocr_job_status_1(file=file, session=session, user=user) - return {"result": result} + try: + columns, rows = get_preview_data(file_job) + except Exception as exc: + logger.error("Failed to fetch OCR preview for file %s: %s", file_id, exc) + raise HTTPException(status_code=502, detail="Failed to load result data") from exc + + return FilePreviewResponse( + file_id=file.id, + filename=file.filename, + columns=columns, + rows=rows, + row_count=len(rows), + markdown_url=file_job.markdown_url, + ) diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py index 53bf929cf7..6dc32dfd74 100644 --- a/backend/app/files/schemas.py +++ b/backend/app/files/schemas.py @@ -1,5 +1,6 @@ import uuid from datetime import datetime +from typing import Any from sqlmodel import Field, SQLModel @@ -57,3 +58,22 @@ class FileJobPublic(SQLModel): class FileWithJobPublic(FilePublic): """FilePublic enriched with its associated FileJob (if any).""" job: FileJobPublic | None = None + + +# --------------------------------------------------------------------------- +# Result preview schemas +# --------------------------------------------------------------------------- + +class FilePreviewResponse(SQLModel): + """Parsed OCR result table for a file, ready to render in the front end. + + ``columns`` is the ordered list of column headers and ``rows`` is the table + content as a list of ``{column: value}`` records — the same data the JSON + download exports, returned inline for previewing. + """ + file_id: uuid.UUID + filename: str + columns: list[str] + rows: list[dict[str, Any]] + row_count: int + markdown_url: str | None = None diff --git a/backend/app/files/service.py b/backend/app/files/service.py index c66e32e2cb..e2aabd3677 100644 --- a/backend/app/files/service.py +++ b/backend/app/files/service.py @@ -1,5 +1,7 @@ import io +import json from io import StringIO +from typing import Any import pandas as pd from google import genai @@ -9,7 +11,7 @@ from app.api_keys.crud import get_api_key_by_user from app.files.crud import get_file_job_by_file_id from app.files.dependencies import CurrentUser -from app.files.models import File +from app.files.models import File, FileJob from app.files.strategies import DOWNLOAD_STRATEGIES from app.files.utils import get_df_from_result_json @@ -50,6 +52,28 @@ def download_file(session: Session, file: File, type: str = "xlsx") -> tuple[byt return (data_bytes, content_disposition) +def get_preview_data(file_job: FileJob) -> tuple[list[str], list[dict[str, Any]]]: + """Build the OCR result table for previewing. + + Fetches the parsed OCR result from the job's ``json_url`` and returns it as a + ``(columns, rows)`` tuple: ``columns`` is the ordered list of headers and + ``rows`` is the table content as JSON-serialisable ``{column: value}`` + records (``NaN`` values are normalised to ``None``). This is the same table + the JSON download exports, returned inline rather than as a file. + """ + df: DataFrame | None = get_df_from_result_json(file_job.json_url) + if df is None: + return [], [] + + # Drop the internal page-tracking column used only for debugging exports. + df = df.drop(columns=["__page__"], errors="ignore") + + columns = [str(col) for col in df.columns] + # to_json normalises NaN/NaT to null and keeps unicode intact. + rows = json.loads(df.to_json(orient="records", force_ascii=False) or "[]") + + return columns, rows + def get_gemini_response_for_file(input_path: str, output_path: str, *, model: str | None = None) -> None: """Read a local file (CSV or XLSX), send its contents to Gemini with the Vietnamese diff --git a/backend/app/ocrs/constants.py b/backend/app/ocrs/constants.py index 9cbb42e7f5..6a1b7587b7 100644 --- a/backend/app/ocrs/constants.py +++ b/backend/app/ocrs/constants.py @@ -3,3 +3,21 @@ class OcrJobStatus: RUNNING = "running" DONE = "done" FAILED = "failed" + + +class OcrModel: + """Supported PaddleOCR parsing models (values sent to the OCR API).""" + + PADDLEOCR_VL_1_6 = "PaddleOCR-VL-1.6" + PADDLEOCR_VL_1_5 = "PaddleOCR-VL-1.5" + PP_OCRV6 = "PP-OCRv6" + PP_OCRV5 = "PP-OCRv5" + PP_STRUCTURE_V3 = "PP-StructureV3" + + ALL: set[str] = { + PADDLEOCR_VL_1_6, + PADDLEOCR_VL_1_5, + PP_OCRV6, + PP_OCRV5, + PP_STRUCTURE_V3, + } diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 7765ef03e5..1eabd2351d 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -1,4 +1,6 @@ +import json import logging +from typing import Any import requests from sqlmodel import Session @@ -31,7 +33,9 @@ "useChartRecognition": False, } -def post_ocr_jobs(session: Session, file: File, file_url: str) -> tuple[bool, str | None]: +def post_ocr_jobs( + session: Session, file: File, file_url: str, model: str | None = None +) -> tuple[bool, str | None]: """ Submit an OCR job for the given file URL and create a FileJob record. Only posts the job — polling is handled separately. @@ -39,7 +43,7 @@ def post_ocr_jobs(session: Session, file: File, file_url: str) -> tuple[bool, st payload = { "fileUrl": file_url, - "model": settings.OCR_MODEL, + "model": model or settings.OCR_MODEL, "optionalPayload": optional_payload, } @@ -126,20 +130,76 @@ def get_ocr_job_status(file: File, session: SessionDep, user: CurrentUser) -> st return state -def get_ocr_job_status_1(file: File, session: SessionDep, user: CurrentUser) -> OcrJobResponse | None: +def fetch_ocr_table_pages(json_url: str) -> list[str]: """ - Poll the OCR API for job results. Returns a typed OcrJobResponse. - """ - file_job: FileJob | None = get_file_job_by_file_id(session=session, file_id=file.id) - if not file_job: - logger.error("File %s has no FileJob record", file.id) - raise Exception("No FileJob record for this file") - - req_headers = {"Authorization": f"bearer {settings.OCR_API_TOKEN}"} - raw = requests.get(f"{settings.OCR_JOB_URL}/{file_job.job_id}", headers=req_headers) - assert raw.status_code in (200, 404), f"OCR API returned unexpected status code {raw.status_code}" + Fetch the parsed OCR result from its data URL (``json_url``) and return the + extracted table(s) for each page as HTML — the same table data the download + endpoint turns into a spreadsheet (see ``app.files.utils``). - return OcrJobResponse.model_validate(raw.json()) + The result file is JSON Lines — one JSON record per page. Tables live under + ``result.layoutParsingResults[*].prunedResult.parsing_res_list`` as blocks + whose ``block_label`` is ``"table"`` and whose ``block_content`` is HTML. + """ + raw = get_bytes_from_file_url(json_url).decode("utf-8") + pages: list[str] = [] + for record in _parse_result_records(raw): + html = _extract_tables(record) + if html.strip(): + pages.append(html) + return pages + + +def _parse_result_records(raw: str) -> list[Any]: + """Parse the result payload as a single JSON value or as JSON Lines.""" + text = raw.strip() + if not text: + return [] + try: + parsed = json.loads(text) + return parsed if isinstance(parsed, list) else [parsed] + except json.JSONDecodeError: + pass + + records: list[Any] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + logger.warning("Skipping malformed OCR result line") + return records + + +def _extract_tables(record: Any) -> str: + """Pull the table block(s) out of one page record as HTML. + + Mirrors the table lookup in ``app.files.utils.extract_tables_from_ocr``: each + record wraps its payload under ``result``, whose ``layoutParsingResults`` hold + ``prunedResult.parsing_res_list`` blocks; table blocks carry their HTML in + ``block_content``. + """ + if not isinstance(record, dict): + return "" + if isinstance(record.get("result"), dict): + record = record["result"] + results = record.get("layoutParsingResults") or record.get("ocrResults") or [record] + if not isinstance(results, list): + results = [results] + + tables: list[str] = [] + for item in results: + if not isinstance(item, dict): + continue + pruned = item.get("prunedResult") if isinstance(item.get("prunedResult"), dict) else {} + for block in pruned.get("parsing_res_list", []): + if not isinstance(block, dict) or block.get("block_label") != "table": + continue + content = block.get("block_content") + if isinstance(content, str) and content.strip(): + tables.append(content) + return "\n".join(tables) def upload_ocr_job_result(user: CurrentUser, file: File, result: OcrJobResponse, session: SessionDep): @@ -155,4 +215,4 @@ def upload_ocr_job_result(user: CurrentUser, file: File, result: OcrJobResponse, size_delta=file.size, total_pages_delta=result.data.extractProgress.extractedPages, # ty:ignore[unresolved-attribute] file_count_delta=1 - ) \ No newline at end of file + ) diff --git a/front-end/app/globals.css b/front-end/app/globals.css index 2e34067d5d..71afeec966 100644 --- a/front-end/app/globals.css +++ b/front-end/app/globals.css @@ -600,7 +600,7 @@ a { color: inherit; text-decoration: none; } .sb-toggle { flex: none; width: 30px; height: 30px; border-radius: 7px; display: grid; place-items: center; background: transparent; border: 1px solid var(--line); color: var(--fg-dim); transition: all 0.16s; } .sb-toggle:hover { color: var(--fg); border-color: var(--fg-faint); background: var(--wash-04); } .collapsed .sb-head { justify-content: center; } -.collapsed .sb-toggle { display: none; } +.collapsed .sb-brand { display: none; } .sb-section-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--fg-faint); padding: 22px 22px 8px; } .collapsed .sb-section-label { opacity: 0; } @@ -790,8 +790,28 @@ a { color: inherit; text-decoration: none; } .fq-x { flex: none; width: 28px; height: 28px; border-radius: 7px; display: grid; place-items: center; border: 1px solid transparent; background: transparent; color: var(--fg-dim); } .fq-x:hover { color: var(--bad); background: var(--wash-05); } .fq-empty { padding: 40px 20px; text-align: center; color: var(--fg-faint); font-family: var(--font-mono); font-size: 12.5px; } -.fq-foot { padding: 16px 18px; border-top: 1px solid var(--line); display: flex; gap: 10px; } -.fq-foot .btn { flex: 1; } +.fq-controls { padding: 14px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: flex-end; gap: 12px; } +.fq-controls .fq-model { flex: 1; padding: 0; min-width: 0; } +.fq-controls .btn { flex: none; } +.fq-model { padding: 14px 18px 0; display: flex; flex-direction: column; gap: 6px; } +.fq-model label { + font-family: var(--font-mono); + font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; + color: var(--fg-dim); +} +.fq-model select { + font-family: var(--font-mono); + font-size: 14px; + padding: 10px 13px; + border-radius: var(--r); + border: 1px solid var(--line-bold); + background: var(--surface-2); + color: var(--fg); + outline: none; + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} +.fq-model select:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-dim); } +.fq-model select:disabled { opacity: 0.6; cursor: not-allowed; } /* ---------- settings ---------- */ .settings-wrap { max-width: 760px; } @@ -867,6 +887,7 @@ a { color: inherit; text-decoration: none; } } .dash.mobile-open .sidebar { transform: none; } .dash.collapsed { --sb: 1fr; } + .collapsed .sb-brand { display: flex; } .collapsed .sb-brand .wm { opacity: 1; width: auto; } .collapsed .sb-item .lbl, .collapsed .sb-item .count { display: block; } .collapsed .sb-item { justify-content: flex-start; padding: 10px 12px; } @@ -925,3 +946,55 @@ a { color: inherit; text-decoration: none; } .admin-nav.open a { padding: 12px; border-bottom: none; border-radius: var(--r); } .admin-nav.open a.active { background: var(--bad-dim); } } + +/* ---- Result preview modal ---- */ +.modal-overlay { + position: fixed; inset: 0; z-index: 80; + display: flex; align-items: center; justify-content: center; + padding: clamp(12px, 4vw, 40px); + background: oklch(0 0 0 / 0.45); backdrop-filter: blur(4px); + animation: fade-in 0.15s ease; +} +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } +.modal { + display: flex; flex-direction: column; + width: 100%; max-width: 820px; max-height: 86vh; + background: var(--surface); border: 1px solid var(--line); + border-radius: var(--r-lg); box-shadow: var(--shadow); overflow: hidden; +} +.modal-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; + padding: 16px 20px; border-bottom: 1px solid var(--line); +} +.modal-title { font-family: var(--font-display); font-size: 15px; font-weight: 600; color: var(--fg); } +.modal-sub { font-family: var(--font-mono); font-size: 12px; color: var(--fg-muted); margin-top: 2px; word-break: break-all; } +.modal-close { + display: grid; place-items: center; width: 30px; height: 30px; flex-shrink: 0; + border: 1px solid var(--line); border-radius: var(--r); color: var(--fg-muted); + background: transparent; transition: color 0.15s, background 0.15s; +} +.modal-close:hover { color: var(--fg); background: var(--wash-04); } +.modal-body { padding: 18px 20px; overflow-y: auto; } +.preview-body-scroll { display: flex; flex-direction: column; gap: 18px; } +.preview-state { padding: 28px 0; text-align: center; color: var(--fg-muted); font-size: 14px; } +.preview-page-label { font-family: var(--font-mono); font-size: 11px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px; } +.preview-md { + margin: 0; padding: 14px; border: 1px solid var(--line-soft); border-radius: var(--r); + background: var(--surface-2); color: var(--fg); + font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6; + white-space: pre-wrap; word-break: break-word; overflow-x: auto; +} +.preview-table { overflow-x: auto; border: 1px solid var(--line-soft); border-radius: var(--r); } +.preview-table table { border-collapse: collapse; width: 100%; font-size: 12.5px; color: var(--fg); } +.preview-table th, .preview-table td { + border: 1px solid var(--line-soft); padding: 7px 10px; text-align: left; vertical-align: top; +} +.preview-table th { background: var(--surface-2); font-weight: 600; } +.preview-table tr:nth-child(even) td { background: var(--surface-2); } +.modal-foot { display: flex; justify-content: flex-end; padding: 12px 20px; border-top: 1px solid var(--line); } +.ghost-link { + display: inline-flex; align-items: center; gap: 6px; + font-family: var(--font-mono); font-size: 12px; color: var(--fg-muted); + transition: color 0.15s; +} +.ghost-link:hover { color: var(--cyan); } diff --git a/front-end/components/dashboard/DocumentsView.tsx b/front-end/components/dashboard/DocumentsView.tsx index 6edda9ae0a..5fd1501f27 100644 --- a/front-end/components/dashboard/DocumentsView.tsx +++ b/front-end/components/dashboard/DocumentsView.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { Download, Eye, FileText, Image as ImageIcon, RefreshCw, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import Pill from "@/components/ui/Pill"; +import PreviewModal from "@/components/dashboard/PreviewModal"; import { apiMessage } from "@/lib/api"; import { FilesService } from "@/lib/client"; import { downloadExport, toDocRow } from "@/lib/files"; @@ -11,6 +12,15 @@ import type { DocRow, DocStatus } from "@/lib/data"; type StatusFilter = "all" | DocStatus; +interface PreviewState { + name: string; + columns: string[]; + rows: Record[]; + markdownUrl: string | null; + loading: boolean; + error: string | null; +} + export default function DocumentsView() { const t = useTranslations("documents"); const tc = useTranslations("common"); @@ -21,6 +31,7 @@ export default function DocumentsView() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); + const [preview, setPreview] = useState(null); const load = useCallback(async () => { setError(null); @@ -57,11 +68,19 @@ export default function DocumentsView() { const viewResult = async (d: DocRow) => { setBusyId(d.id); + setPreview({ name: d.name, columns: [], rows: [], markdownUrl: null, loading: true, error: null }); try { - const res = (await FilesService.getFileResultUrl({ fileId: d.id })) as { result?: string }; - if (typeof res?.result === "string") window.open(res.result, "_blank", "noopener"); + const res = await FilesService.previewFileResult({ fileId: d.id }); + setPreview({ + name: res.filename || d.name, + columns: res.columns, + rows: res.rows, + markdownUrl: res.markdown_url ?? null, + loading: false, + error: null, + }); } catch (err) { - setError(apiMessage(err)); + setPreview((p) => (p ? { ...p, loading: false, error: apiMessage(err) } : p)); } finally { setBusyId(null); } @@ -194,6 +213,18 @@ export default function DocumentsView() { {t("showing", { shown: filtered.length, total: docs.length })} + + {preview && ( + setPreview(null)} + /> + )} ); } diff --git a/front-end/components/dashboard/PreviewModal.tsx b/front-end/components/dashboard/PreviewModal.tsx new file mode 100644 index 0000000000..34be566984 --- /dev/null +++ b/front-end/components/dashboard/PreviewModal.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect } from "react"; +import { ExternalLink, X } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type PreviewRow = Record; + +interface PreviewModalProps { + title: string; + columns: string[]; + rows: PreviewRow[]; + markdownUrl: string | null; + loading: boolean; + error: string | null; + onClose: () => void; +} + +function formatCell(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export default function PreviewModal({ + title, + columns, + rows, + markdownUrl, + loading, + error, + onClose, +}: PreviewModalProps) { + const t = useTranslations("documents"); + const tc = useTranslations("common"); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const isEmpty = columns.length === 0 || rows.length === 0; + + return ( +
    +
    e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label={t("previewTitle")} + > +
    +
    +
    {t("previewTitle")}
    +
    {title}
    +
    + +
    + +
    + {loading &&
    {tc("loading")}
    } + {!loading && error &&
    {error}
    } + {!loading && !error && isEmpty && ( +
    {t("previewEmpty")}
    + )} + {!loading && !error && !isEmpty && ( +
    + + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
    {col}
    {formatCell(row[col])}
    +
    + )} +
    + + {markdownUrl && ( + + )} +
    +
    + ); +} diff --git a/front-end/components/dashboard/UploadView.tsx b/front-end/components/dashboard/UploadView.tsx index 473e6cc680..4f883ae830 100644 --- a/front-end/components/dashboard/UploadView.tsx +++ b/front-end/components/dashboard/UploadView.tsx @@ -1,10 +1,11 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Download, FilePlus2, FileText, RefreshCw, Sparkles, UploadCloud, X } from "lucide-react"; +import { Download, FileText, RefreshCw, Sparkles, UploadCloud, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { apiMessage } from "@/lib/api"; import { FilesService } from "@/lib/client"; +import { DEFAULT_OCR_MODEL, OCR_MODELS } from "@/constants/ocr"; import { downloadExport, jobProgress, jobStatus } from "@/lib/files"; type FileStatus = "queued" | "uploading" | "parsing" | "done" | "error"; @@ -31,6 +32,7 @@ export default function UploadView() { const [dragging, setDragging] = useState(false); const [files, setFiles] = useState([]); const [parsing, setParsing] = useState(false); + const [model, setModel] = useState(DEFAULT_OCR_MODEL); const inputRef = useRef(null); const filesRef = useRef(files); filesRef.current = files; @@ -117,7 +119,7 @@ export default function UploadView() { queued.map(async (f) => { patch(f.uid, { status: "uploading", progress: 0 }); try { - const uploaded = await FilesService.uploadFileEndpoint({ formData: { file: f.file } }); + const uploaded = await FilesService.uploadFileEndpoint({ formData: { file: f.file, model } }); patch(f.uid, { status: "parsing", fileId: uploaded.id, progress: 5 }); } catch (err) { patch(f.uid, { status: "error", error: apiMessage(err) }); @@ -205,6 +207,42 @@ export default function UploadView() { )} + +
    +
    + + +
    + +
    +
    {queued === 0 && (
    @@ -268,22 +306,6 @@ export default function UploadView() {
    ))}
    -
    - - -
    ); diff --git a/front-end/constants/ocr.ts b/front-end/constants/ocr.ts new file mode 100644 index 0000000000..ddb9ada223 --- /dev/null +++ b/front-end/constants/ocr.ts @@ -0,0 +1,19 @@ +export type OcrModelBadge = "new" | "offline"; + +export interface OcrModelOption { + /** Value sent to the backend; must match backend OcrModel.ALL. */ + value: string; + label: string; + badge?: OcrModelBadge; +} + +/** PaddleOCR parsing models the user can pick before parsing a document. */ +export const OCR_MODELS: OcrModelOption[] = [ + { value: "PaddleOCR-VL-1.6", label: "PaddleOCR-VL-1.6", badge: "new" }, + { value: "PaddleOCR-VL-1.5", label: "PaddleOCR-VL-1.5", badge: "offline" }, + { value: "PP-OCRv6", label: "PP-OCRv6", badge: "new" }, + { value: "PP-OCRv5", label: "PP-OCRv5", badge: "offline" }, + { value: "PP-StructureV3", label: "PP-StructureV3" }, +]; + +export const DEFAULT_OCR_MODEL = OCR_MODELS[0].value; diff --git a/front-end/lib/client/schemas.gen.ts b/front-end/lib/client/schemas.gen.ts index 6ad3d9cff9..c695e45078 100644 --- a/front-end/lib/client/schemas.gen.ts +++ b/front-end/lib/client/schemas.gen.ts @@ -84,6 +84,17 @@ export const Body_files_upload_file_endpointSchema = { type: 'string', format: 'binary', title: 'File' + }, + model: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Model' } }, type: 'object', @@ -275,6 +286,58 @@ export const FileJobPublicSchema = { title: 'FileJobPublic' } as const; +export const FilePreviewResponseSchema = { + properties: { + file_id: { + type: 'string', + format: 'uuid', + title: 'File Id' + }, + filename: { + type: 'string', + title: 'Filename' + }, + columns: { + items: { + type: 'string' + }, + type: 'array', + title: 'Columns' + }, + rows: { + items: { + additionalProperties: true, + type: 'object' + }, + type: 'array', + title: 'Rows' + }, + row_count: { + type: 'integer', + title: 'Row Count' + }, + markdown_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Markdown Url' + } + }, + type: 'object', + required: ['file_id', 'filename', 'columns', 'rows', 'row_count'], + title: 'FilePreviewResponse', + description: `Parsed OCR result table for a file, ready to render in the front end. + +\`\`columns\`\` is the ordered list of column headers and \`\`rows\`\` is the table +content as a list of \`\`{column: value}\`\` records — the same data the JSON +download exports, returned inline for previewing.` +} as const; + export const FilePublicSchema = { properties: { filename: { diff --git a/front-end/lib/client/sdk.gen.ts b/front-end/lib/client/sdk.gen.ts index 7e713224ff..fb81c062b1 100644 --- a/front-end/lib/client/sdk.gen.ts +++ b/front-end/lib/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesGetFileJobData, FilesGetFileJobResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, FilesGetFileResultUrlData, FilesGetFileResultUrlResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreatePaymentData, TopupCreatePaymentResponse, TopupTopupReturnResponse, TopupGetMyBalanceResponse, TopupGetMyTransactionsData, TopupGetMyTransactionsResponse, TopupTopupIpnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; +import type { ApiKeysListApiKeysResponse, ApiKeysCreateApiKeyData, ApiKeysCreateApiKeyResponse, ApiKeysDeleteApiKeyData, ApiKeysDeleteApiKeyResponse, FilesListOcrModelsResponse, FilesUploadFileEndpointData, FilesUploadFileEndpointResponse, FilesListFilesData, FilesListFilesResponse, FilesUpdateFileJobStatusEndpointData, FilesUpdateFileJobStatusEndpointResponse, FilesGetFileStatusData, FilesGetFileStatusResponse, FilesGetFileJobData, FilesGetFileJobResponse, FilesDownloadTableExcelFileData, FilesDownloadTableExcelFileResponse, FilesDownloadNewVersionExcelData, FilesDownloadNewVersionExcelResponse, FilesGetFilesBatchStatusData, FilesGetFilesBatchStatusResponse, FilesPreviewFileResultData, FilesPreviewFileResultResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemEndpointData, ItemsCreateItemEndpointResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemEndpointData, ItemsUpdateItemEndpointResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, StoragesGetMyStorageStatResponse, TopupGetTopupPackagesResponse, TopupCreatePaymentData, TopupCreatePaymentResponse, TopupTopupReturnResponse, TopupGetMyBalanceResponse, TopupGetMyTransactionsData, TopupGetMyTransactionsResponse, TopupTopupIpnResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserEndpointData, UsersCreateUserEndpointResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserEndpointData, UsersUpdateUserEndpointResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsClearAllFilesResponse } from './types.gen'; export class ApiKeysService { /** @@ -61,9 +61,25 @@ export class ApiKeysService { } export class FilesService { + /** + * List Ocr Models + * List the OCR models a user can choose from when parsing a document. + * @returns string Successful Response + * @throws ApiError + */ + public static listOcrModels(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/files/models' + }); + } + /** * Upload File Endpoint * Upload a file to R2/S3 storage. + * + * `model` selects which PaddleOCR model parses the document. When omitted, + * the configured default (settings.OCR_MODEL) is used. * @param data The data for the request. * @param data.formData * @returns FilePublic Successful Response @@ -242,17 +258,19 @@ export class FilesService { } /** - * Get File Result Url - * Get the presigned URL for the OCR result JSON file in R2 for a given file ID. + * Preview File Result + * Fetch the parsed OCR result for a file from its stored ``json_url`` and + * return the extracted table as JSON (``columns`` + ``rows``), ready to render + * in the front end. This is the same table data the download endpoint exports. * @param data The data for the request. * @param data.fileId - * @returns unknown Successful Response + * @returns FilePreviewResponse Successful Response * @throws ApiError */ - public static getFileResultUrl(data: FilesGetFileResultUrlData): CancelablePromise { + public static previewFileResult(data: FilesPreviewFileResultData): CancelablePromise { return __request(OpenAPI, { method: 'GET', - url: '/api/v1/files/{file_id}/result_url', + url: '/api/v1/files/{file_id}/preview', path: { file_id: data.fileId }, diff --git a/front-end/lib/client/types.gen.ts b/front-end/lib/client/types.gen.ts index fba4496d3a..eed902537a 100644 --- a/front-end/lib/client/types.gen.ts +++ b/front-end/lib/client/types.gen.ts @@ -18,6 +18,7 @@ export type ApiKeysList = { export type Body_files_upload_file_endpoint = { file: (Blob | File); + model?: (string | null); }; export type Body_login_login_access_token = { @@ -55,6 +56,24 @@ export type FileJobPublic = { created_at?: (string | null); }; +/** + * Parsed OCR result table for a file, ready to render in the front end. + * + * ``columns`` is the ordered list of column headers and ``rows`` is the table + * content as a list of ``{column: value}`` records — the same data the JSON + * download exports, returned inline for previewing. + */ +export type FilePreviewResponse = { + file_id: string; + filename: string; + columns: Array<(string)>; + rows: Array<{ + [key: string]: unknown; + }>; + row_count: number; + markdown_url?: (string | null); +}; + export type FilePublic = { filename: string; content_type: string; @@ -247,6 +266,8 @@ export type ApiKeysDeleteApiKeyData = { export type ApiKeysDeleteApiKeyResponse = (unknown); +export type FilesListOcrModelsResponse = (Array<(string)>); + export type FilesUploadFileEndpointData = { formData: Body_files_upload_file_endpoint; }; @@ -298,11 +319,11 @@ export type FilesGetFilesBatchStatusData = { export type FilesGetFilesBatchStatusResponse = (Array); -export type FilesGetFileResultUrlData = { +export type FilesPreviewFileResultData = { fileId: string; }; -export type FilesGetFileResultUrlResponse = (unknown); +export type FilesPreviewFileResultResponse = (FilePreviewResponse); export type ItemsReadItemsData = { limit?: number; diff --git a/front-end/messages/en.json b/front-end/messages/en.json index 563063d18b..e8407f9626 100644 --- a/front-end/messages/en.json +++ b/front-end/messages/en.json @@ -10,7 +10,8 @@ "menu": "Menu", "back": "Back to site", "notifications": "Notifications", - "search": "Search" + "search": "Search", + "close": "Close" }, "brand": { "name": "TABULA", @@ -227,7 +228,12 @@ "empty": "No documents match your filters.", "showing": "Showing {shown} of {total} documents", "download": "Download Excel", - "view": "View result" + "view": "View result", + "previewTitle": "Parsed result", + "previewPage": "Page {n}", + "previewEmpty": "No text could be extracted from this document.", + "previewNoData": "Result data is not available for this document yet.", + "previewOpenRaw": "Open raw markdown" }, "upload": { "dropTitle": "Drag & drop documents here", @@ -249,7 +255,10 @@ "parse": "Parse {count}", "parsing": "Parsing…", "download": "Download", - "remove": "Remove" + "remove": "Remove", + "model": "Parsing model", + "badgeNew": "New", + "badgeOffline": "Offline soon" }, "settings": { "parsingTitle": "Parsing", diff --git a/front-end/messages/vi.json b/front-end/messages/vi.json index 0b04c03e1b..d6590752b7 100644 --- a/front-end/messages/vi.json +++ b/front-end/messages/vi.json @@ -10,7 +10,8 @@ "menu": "Menu", "back": "Về trang chính", "notifications": "Thông báo", - "search": "Tìm kiếm" + "search": "Tìm kiếm", + "close": "Đóng" }, "brand": { "name": "TABULA", @@ -227,7 +228,12 @@ "empty": "Không có tài liệu nào khớp bộ lọc.", "showing": "Hiển thị {shown} trên {total} tài liệu", "download": "Tải Excel", - "view": "Xem kết quả" + "view": "Xem kết quả", + "previewTitle": "Kết quả phân tích", + "previewPage": "Trang {n}", + "previewEmpty": "Không trích xuất được văn bản nào từ tài liệu này.", + "previewNoData": "Dữ liệu kết quả của tài liệu này chưa sẵn sàng.", + "previewOpenRaw": "Mở markdown gốc" }, "upload": { "dropTitle": "Kéo & thả tài liệu vào đây", @@ -249,7 +255,10 @@ "parse": "Phân tích {count}", "parsing": "Đang phân tích…", "download": "Tải về", - "remove": "Gỡ bỏ" + "remove": "Gỡ bỏ", + "model": "Mô hình phân tích", + "badgeNew": "Mới", + "badgeOffline": "Sắp offline" }, "settings": { "parsingTitle": "Phân tích", diff --git a/front-end/next.config.mjs b/front-end/next.config.mjs index a69b399998..2eb1ec1413 100644 --- a/front-end/next.config.mjs +++ b/front-end/next.config.mjs @@ -5,6 +5,13 @@ const withNextIntl = createNextIntlPlugin("./lib/request.ts"); /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + webpack: (config) => { + // Silence the harmless `node-fetch` resolution warnings emitted by webpack's + // filesystem cache when snapshotting build deps of Next's bundled `next/font/google` + // loader. The cache falls back gracefully; only the noisy warnings need quieting. + config.infrastructureLogging = { level: "error" }; + return config; + }, }; export default withNextIntl(nextConfig); From 40de83f221a074c4cf2907382e646da39c556d16 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 14 Jun 2026 02:40:52 +0700 Subject: [PATCH 29/30] add model --- .../b1c2d3e4f5a6_add_model_to_file_jobs.py | 27 +++++++++++++++++++ backend/app/files/models.py | 1 + backend/app/files/schemas.py | 2 ++ backend/app/ocrs/service.py | 5 +++- front-end/app/globals.css | 1 + .../components/dashboard/DocumentsView.tsx | 6 +++-- .../components/dashboard/PreviewModal.tsx | 25 ++++++++++++++++- front-end/lib/client/schemas.gen.ts | 11 ++++++++ front-end/lib/client/types.gen.ts | 1 + front-end/lib/data.ts | 1 + front-end/lib/files.ts | 1 + front-end/messages/en.json | 1 + front-end/messages/vi.json | 1 + 13 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py diff --git a/backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py b/backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py new file mode 100644 index 0000000000..7736152ae7 --- /dev/null +++ b/backend/app/alembic/versions/b1c2d3e4f5a6_add_model_to_file_jobs.py @@ -0,0 +1,27 @@ +"""add model to file_jobs + +Revision ID: b1c2d3e4f5a6 +Revises: a7b8c9d0e1f2 +Create Date: 2026-06-14 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b1c2d3e4f5a6' +down_revision = 'a7b8c9d0e1f2' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'file_jobs', + sa.Column('model', sa.String(length=100), nullable=True), + ) + + +def downgrade(): + op.drop_column('file_jobs', 'model') diff --git a/backend/app/files/models.py b/backend/app/files/models.py index e6a5089af4..54535d1005 100644 --- a/backend/app/files/models.py +++ b/backend/app/files/models.py @@ -32,6 +32,7 @@ class FileJob(SQLModel, table=True): job_id: str = Field(max_length=255, index=True) file_id: uuid.UUID = Field(foreign_key="files.id", nullable=False, ondelete="CASCADE") state: str = Field(default=OcrJobStatus.PENDING, max_length=50) + model: str | None = Field(default=None, max_length=100) total_pages: int | None = None extracted_pages: int | None = None start_time: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) # ty:ignore[invalid-argument-type] diff --git a/backend/app/files/schemas.py b/backend/app/files/schemas.py index 6dc32dfd74..7664499ff1 100644 --- a/backend/app/files/schemas.py +++ b/backend/app/files/schemas.py @@ -35,6 +35,7 @@ class FileJobCreate(SQLModel): job_id: str = Field(max_length=255) file_id: uuid.UUID state: str = Field(max_length=50) + model: str | None = Field(default=None, max_length=100) total_pages: int | None = None extracted_pages: int | None = None json_url: str | None = Field(default=None, max_length=4000) @@ -47,6 +48,7 @@ class FileJobPublic(SQLModel): job_id: str file_id: uuid.UUID state: str + model: str | None = None total_pages: int | None = None extracted_pages: int | None = None json_url: str | None = None diff --git a/backend/app/ocrs/service.py b/backend/app/ocrs/service.py index 1eabd2351d..08a74ed445 100644 --- a/backend/app/ocrs/service.py +++ b/backend/app/ocrs/service.py @@ -41,9 +41,10 @@ def post_ocr_jobs( Only posts the job — polling is handled separately. """ + selected_model = model or settings.OCR_MODEL payload = { "fileUrl": file_url, - "model": model or settings.OCR_MODEL, + "model": selected_model, "optionalPayload": optional_payload, } @@ -58,6 +59,7 @@ def post_ocr_jobs( file_job_in=FileJobCreate( file_id=file.id, state=OcrJobStatus.FAILED, + model=selected_model, err_msg=submit_response.msg, ), ) @@ -74,6 +76,7 @@ def post_ocr_jobs( job_id=job_id, file_id=file.id, state=OcrJobStatus.RUNNING, + model=selected_model, ), ) diff --git a/front-end/app/globals.css b/front-end/app/globals.css index 71afeec966..a0d933c533 100644 --- a/front-end/app/globals.css +++ b/front-end/app/globals.css @@ -962,6 +962,7 @@ a { color: inherit; text-decoration: none; } background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); box-shadow: var(--shadow); overflow: hidden; } +.preview-modal { max-width: 1200px; max-height: 92vh; } .modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 16px 20px; border-bottom: 1px solid var(--line); diff --git a/front-end/components/dashboard/DocumentsView.tsx b/front-end/components/dashboard/DocumentsView.tsx index 5fd1501f27..a89f4a2c48 100644 --- a/front-end/components/dashboard/DocumentsView.tsx +++ b/front-end/components/dashboard/DocumentsView.tsx @@ -137,6 +137,7 @@ export default function DocumentsView() {
    {t("colFilename")} {t("colJobId")} {t("colType")}{t("colModel")} {t("colUploaded")} {t("colStatus")} {t("colActions")}
    {tc("loading")}{tc("loading")}
    {t("empty")}{t("empty")}
    {d.id.slice(0, 8)} {d.type === "pdf" ? t("typePdf") : t("typeImage")}{d.model ?? "—"} {d.date} {d.status === "proc" && d.progress != null ? ( diff --git a/front-end/components/dashboard/PreviewModal.tsx b/front-end/components/dashboard/PreviewModal.tsx index 34be566984..59c836ec23 100644 --- a/front-end/components/dashboard/PreviewModal.tsx +++ b/front-end/components/dashboard/PreviewModal.tsx @@ -16,10 +16,33 @@ interface PreviewModalProps { onClose: () => void; } +// Add comma thousands separators to a numeric string while keeping the dot +// decimal and any sign. Operates on the string so large integers (e.g. IDs) +// don't lose precision through Number(). +function groupNumberString(numeric: string): string { + const match = /^(-?)(\d+)(\.\d+)?$/.exec(numeric); + if (!match) return numeric; + const [, sign, intPart, decPart = ""] = match; + const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return `${sign}${grouped}${decPart}`; +} + function formatCell(value: unknown): string { if (value === null || value === undefined) return ""; + if (typeof value === "number") { + return Number.isFinite(value) + ? groupNumberString(String(value)) + : String(value); + } if (typeof value === "object") return JSON.stringify(value); - return String(value); + + const str = String(value); + // If the text is a number only, render it with comma/dot grouping. + const trimmed = str.trim(); + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return groupNumberString(trimmed); + } + return str; } export default function PreviewModal({ diff --git a/front-end/lib/client/schemas.gen.ts b/front-end/lib/client/schemas.gen.ts index c695e45078..713ea325b3 100644 --- a/front-end/lib/client/schemas.gen.ts +++ b/front-end/lib/client/schemas.gen.ts @@ -213,6 +213,17 @@ export const FileJobPublicSchema = { type: 'string', title: 'State' }, + model: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Model' + }, total_pages: { anyOf: [ { diff --git a/front-end/lib/client/types.gen.ts b/front-end/lib/client/types.gen.ts index eed902537a..48b0db7fe5 100644 --- a/front-end/lib/client/types.gen.ts +++ b/front-end/lib/client/types.gen.ts @@ -48,6 +48,7 @@ export type FileJobPublic = { job_id: string; file_id: string; state: string; + model?: (string | null); total_pages?: (number | null); extracted_pages?: (number | null); json_url?: (string | null); diff --git a/front-end/lib/data.ts b/front-end/lib/data.ts index 3babe650ce..68b95cdc51 100644 --- a/front-end/lib/data.ts +++ b/front-end/lib/data.ts @@ -10,6 +10,7 @@ export interface DocRow { status: DocStatus; pages: number; progress?: number; + model?: string | null; } /** Mock parse jobs — file names/ids are data, not translatable UI strings. */ diff --git a/front-end/lib/files.ts b/front-end/lib/files.ts index b7a8d8e558..01760e1666 100644 --- a/front-end/lib/files.ts +++ b/front-end/lib/files.ts @@ -46,6 +46,7 @@ export function toDocRow(f: FileWithJobPublic): DocRow { status: jobStatus(f.job), pages: f.job?.total_pages ?? 0, progress: jobProgress(f.job), + model: f.job?.model ?? null, }; } diff --git a/front-end/messages/en.json b/front-end/messages/en.json index e8407f9626..37e497b255 100644 --- a/front-end/messages/en.json +++ b/front-end/messages/en.json @@ -220,6 +220,7 @@ "colFilename": "Filename", "colJobId": "Job ID", "colType": "Type", + "colModel": "Model", "colUploaded": "Uploaded", "colStatus": "Status", "colActions": "Actions", diff --git a/front-end/messages/vi.json b/front-end/messages/vi.json index d6590752b7..e530a21d47 100644 --- a/front-end/messages/vi.json +++ b/front-end/messages/vi.json @@ -220,6 +220,7 @@ "colFilename": "Tên tệp", "colJobId": "Mã công việc", "colType": "Loại", + "colModel": "Mô hình", "colUploaded": "Đã tải lên", "colStatus": "Trạng thái", "colActions": "Thao tác", From f631f16ca37c9ce8e689c1b0d3a6cb6fcd046ee1 Mon Sep 17 00:00:00 2001 From: nguyentien4106 Date: Sun, 14 Jun 2026 02:49:58 +0700 Subject: [PATCH 30/30] add option to download --- front-end/app/globals.css | 5 ++ .../components/dashboard/DocumentsView.tsx | 67 +++++++++++++++---- front-end/messages/en.json | 3 +- front-end/messages/vi.json | 3 +- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/front-end/app/globals.css b/front-end/app/globals.css index a0d933c533..5320449ff6 100644 --- a/front-end/app/globals.css +++ b/front-end/app/globals.css @@ -748,6 +748,11 @@ a { color: inherit; text-decoration: none; } .row-actions button.dl:hover { color: var(--cyan); border-color: color-mix(in oklch, var(--cyan) 30%, transparent); } .row-actions button.del:hover { color: var(--bad); border-color: oklch(0.68 0.17 25 / 0.3); } .row-actions button:disabled { opacity: 0.3; cursor: not-allowed; } +.dl-wrap { position: relative; display: inline-flex; } +.row-actions .dl { width: auto; padding: 0 7px; gap: 2px; display: inline-flex; align-items: center; } +.dl-menu { position: absolute; right: 0; top: 100%; z-index: 40; margin-top: 6px; min-width: 150px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r); padding: 6px; box-shadow: var(--shadow); } +.dl-menu button { display: flex; width: 100%; height: auto; padding: 8px 10px; border-radius: 6px; background: transparent; border: 0; color: var(--fg-muted); font-family: var(--font-mono); font-size: 12.5px; text-align: left; cursor: pointer; } +.dl-menu button:hover { background: var(--wash-04); color: var(--fg); border-color: transparent; } .table-foot { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 12px; color: var(--fg-dim); flex-wrap: wrap; gap: 10px; } .empty-row td { text-align: center; padding: 44px; color: var(--fg-faint); font-family: var(--font-mono); font-size: 13px; } diff --git a/front-end/components/dashboard/DocumentsView.tsx b/front-end/components/dashboard/DocumentsView.tsx index a89f4a2c48..8fcb481b6a 100644 --- a/front-end/components/dashboard/DocumentsView.tsx +++ b/front-end/components/dashboard/DocumentsView.tsx @@ -1,7 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { Download, Eye, FileText, Image as ImageIcon, RefreshCw, Search } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ChevronDown, Download, Eye, FileText, Image as ImageIcon, RefreshCw, Search } from "lucide-react"; import { useTranslations } from "next-intl"; import Pill from "@/components/ui/Pill"; import PreviewModal from "@/components/dashboard/PreviewModal"; @@ -12,6 +12,15 @@ import type { DocRow, DocStatus } from "@/lib/data"; type StatusFilter = "all" | DocStatus; +type DownloadFormat = "xlsx" | "csv" | "json" | "html"; + +const DOWNLOAD_FORMATS: { type: DownloadFormat; label: string }[] = [ + { type: "xlsx", label: "Excel (.xlsx)" }, + { type: "csv", label: "CSV (.csv)" }, + { type: "json", label: "JSON (.json)" }, + { type: "html", label: "HTML (.html)" }, +]; + interface PreviewState { name: string; columns: string[]; @@ -31,7 +40,9 @@ export default function DocumentsView() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); + const [menuId, setMenuId] = useState(null); const [preview, setPreview] = useState(null); + const menuRef = useRef(null); const load = useCallback(async () => { setError(null); @@ -50,15 +61,26 @@ export default function DocumentsView() { void load(); }, [load]); + // Close the download format menu when clicking anywhere outside it. + useEffect(() => { + if (!menuId) return; + const onClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuId(null); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, [menuId]); + const refresh = () => { setRefreshing(true); void load(); }; - const download = async (d: DocRow) => { + const download = async (d: DocRow, type: DownloadFormat) => { + setMenuId(null); setBusyId(d.id); try { - await downloadExport(d.id, d.name, "xlsx"); + await downloadExport(d.id, d.name, type); } catch (err) { setError(apiMessage(err)); } finally { @@ -188,15 +210,34 @@ export default function DocumentsView() {
    - +
    + + {menuId === d.id && ( +
    + {DOWNLOAD_FORMATS.map((f) => ( + + ))} +
    + )} +