commit 4b80fe28fe03ec572c44eaa0215a8466f7bfbf49 Author: Damien Coles Date: Mon Jan 26 10:30:49 2026 -0500 public-ready-init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..326ca94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Django +*.log +local_settings.py +db.sqlite3 +*.pot +*.pyc +staticfiles/ +media/ + +# Environment files +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# SvelteKit +.svelte-kit/ + +# Testing +.coverage +htmlcov/ +.pytest_cache/ + +# Misc +.DS_Store +Thumbs.db +*.bak + +# Docker +docker-compose.override.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..24ed048 --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +# Nexus 3 + +A modern business management platform built with Django GraphQL and SvelteKit. Nexus 3 represents a significant architectural evolution from its predecessors, featuring a GraphQL API, modular backend architecture, and enhanced frontend state management. + +## Improvements Over Nexus 1 and Nexus 2 + +### Architectural Improvements Over Nexus 1 + +| Feature | Nexus 1 | Nexus 3 | +|---------|---------|---------| +| **API Architecture** | REST API | GraphQL API | +| **Frontend Framework** | React (JSX) | SvelteKit with TypeScript | +| **Backend Structure** | Monolithic (single `models.py`, `views.py`) | Modular (separate directories per domain) | +| **State Management** | Prop drilling | Svelte stores with typed operations | +| **Authentication** | JWT with DRF | JWT with graphql-jwt | +| **Type Safety** | None | Full TypeScript support | +| **Code Organization** | Flat structure | Domain-driven design | + +### Architectural Improvements Over Nexus 2 + +| Feature | Nexus 2 | Nexus 3 | +|---------|---------|---------| +| **API Architecture** | REST API (DRF ViewSets) | GraphQL API (Graphene) | +| **Data Fetching** | Multiple REST endpoints | Single GraphQL endpoint with precise queries | +| **Backend Patterns** | MVC with serializers | Repository + Command pattern | +| **Settings Management** | Single `settings.py` | Split settings (base/dev/prod) | +| **Frontend Data Layer** | Direct API calls | Typed GraphQL operations with URQL | +| **Caching** | None | URQL exchange-based caching | +| **Error Handling** | HTTP status codes | GraphQL error extensions | + +### Key Improvements in Nexus 3 + +1. **GraphQL API**: Clients request exactly the data they need, reducing over-fetching and under-fetching +2. **Repository Pattern**: Data access is abstracted through repositories, making testing and maintenance easier +3. **Command Pattern**: Business logic is encapsulated in commands, promoting single responsibility +4. **Factory Pattern**: Object creation is standardized through factories +5. **Split Settings**: Environment-specific configuration (development, production) +6. **Typed Frontend**: Full TypeScript support with generated types for GraphQL operations +7. **URQL Client**: Modern GraphQL client with built-in caching and auth exchange +8. **Modular Structure**: Each domain (accounts, customers, services, etc.) has its own: + - Models + - Repositories + - Commands + - GraphQL types, inputs, queries, and mutations + - Frontend stores and components + +## Tech Stack + +### Backend +- Python 3.11+ +- Django 4.2+ +- Graphene-Django (GraphQL) +- graphql-jwt (Authentication) +- PostgreSQL +- Redis (caching) + +### Frontend +- SvelteKit +- TypeScript +- URQL (GraphQL client) +- TailwindCSS + +## Project Structure + +``` +nexus-3/ +├── backend/ +│ ├── config/ +│ │ └── settings/ +│ │ ├── base.py +│ │ ├── development.py +│ │ └── production.py +│ ├── core/ +│ │ ├── models/ # Domain models +│ │ ├── repositories/ # Data access layer +│ │ ├── commands/ # Business logic +│ │ ├── factories/ # Object creation +│ │ ├── services/ # Service layer +│ │ └── utils/ # Utilities +│ └── graphql_api/ +│ ├── types/ # GraphQL types +│ ├── inputs/ # GraphQL input types +│ ├── queries/ # GraphQL queries +│ └── mutations/ # GraphQL mutations +├── frontend/ +│ └── src/ +│ ├── lib/ +│ │ ├── components/ # UI components by domain +│ │ ├── graphql/ # GraphQL client & operations +│ │ └── stores/ # Svelte stores by domain +│ └── routes/ # SvelteKit routes +└── docker-compose.yml +``` + +## Quick Start + +### Prerequisites +- Python 3.11+ +- Node.js 18+ +- PostgreSQL 15+ (or use Docker) +- Redis (optional, for caching) + +### Using Docker (Recommended) + +```bash +# Clone the repository +git clone +cd nexus-3 + +# Copy environment file +cp backend/.env.example backend/.env +# Edit backend/.env with your settings + +# Start all services +docker-compose up -d + +# Run migrations +docker-compose exec backend python manage.py migrate + +# Create a superuser +docker-compose exec backend python manage.py createsuperuser +``` + +### Manual Setup + +#### Backend + +```bash +cd backend + +# Create virtual environment +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt + +# Copy environment file +cp .env.example .env +# Edit .env with your settings + +# Run migrations +python manage.py migrate + +# Create superuser +python manage.py createsuperuser + +# Start development server +python manage.py runserver +``` + +#### Frontend + +```bash +cd frontend + +# Install dependencies +npm install + +# Start development server +npm run dev +``` + +## Configuration + +### Backend Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `DJANGO_SETTINGS_MODULE` | Settings module to use | `config.settings.development` | +| `DJANGO_SECRET_KEY` | Django secret key (production) | - | +| `DEV_SECRET_KEY` | Django secret key (development) | - | +| `DB_NAME` | Database name | `nexus` | +| `DB_USER` | Database user | `postgres` | +| `DB_PASSWORD` | Database password | - | +| `DB_HOST` | Database host | `localhost` | +| `DB_PORT` | Database port | `5432` | +| `REDIS_URL` | Redis connection URL | `redis://localhost:6379/1` | +| `SENTRY_DSN` | Sentry DSN for error tracking | - | + +### Frontend Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PUBLIC_GRAPHQL_URL` | GraphQL API endpoint | `http://localhost:8000/graphql/` | + +## GraphQL API + +The GraphQL API is available at `/graphql/`. In development, you can use the GraphiQL interface to explore the schema. + +### Example Query + +```graphql +query GetAccounts { + accounts { + id + name + customer { + id + name + } + services { + id + status + scheduledDate + } + } +} +``` + +### Example Mutation + +```graphql +mutation CreateAccount($input: AccountCreateInput!) { + createAccount(input: $input) { + account { + id + name + } + errors + } +} +``` + +## Core Entities + +- **Customers**: Client organizations +- **Accounts**: Individual locations/sites belonging to customers +- **Services**: Scheduled service visits +- **Projects**: Multi-service projects +- **Invoices**: Billing records +- **Labor**: Labor tracking records +- **Revenue**: Revenue tracking +- **Schedules**: Employee schedules +- **Reports**: Generated reports +- **Punchlists**: Customizable service checklists + +## Customizing the Punchlist Feature + +The Punchlist model is designed to be customizable for your specific service workflow. The default fields provide a generic structure with sections for: + +- Front area (customer-facing) +- Main work area +- Equipment +- Back area +- End of visit checklist + +To customize: + +1. Edit `backend/core/models/punchlists/punchlists.py` to add/remove/rename fields +2. Update `backend/graphql_api/types/punchlists.py` to expose the new fields +3. Update `backend/graphql_api/inputs/punchlists/punchlists.py` for create/update inputs +4. Run `python manage.py makemigrations` and `python manage.py migrate` +5. Update the frontend components accordingly + +## Development + +### Running Tests + +```bash +# Backend tests +cd backend +python manage.py test + +# Frontend tests +cd frontend +npm run test +``` + +### Code Style + +```bash +# Backend (using black and isort) +cd backend +black . +isort . + +# Frontend (using prettier and eslint) +cd frontend +npm run lint +npm run format +``` + +## Deployment + +### Production Checklist + +1. Set `DJANGO_SETTINGS_MODULE=config.settings.production` +2. Generate a strong `DJANGO_SECRET_KEY` +3. Configure PostgreSQL with proper credentials +4. Set up Redis for caching +5. Configure ALLOWED_HOSTS and CORS settings +6. Set up SSL/TLS +7. Configure email settings +8. (Optional) Set up Sentry for error tracking + +## License + +MIT License - See LICENSE file for details. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..909fc2e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,31 @@ +# Django Settings +DJANGO_SETTINGS_MODULE=config.settings.development +DJANGO_SECRET_KEY=your-secret-key-here-generate-a-new-one +DEV_SECRET_KEY=your-dev-secret-key-here + +# Database (Production) +DB_NAME=nexus +DB_USER=postgres +DB_PASSWORD=your-database-password +DB_HOST=localhost +DB_PORT=5432 + +# Allowed Hosts (Production) +DJANGO_ALLOWED_HOST=your-domain.com + +# Email Settings (Production) +EMAIL_HOST=smtp.your-email-provider.com +EMAIL_PORT=587 +EMAIL_HOST_USER=your-email@your-domain.com +EMAIL_HOST_PASSWORD=your-email-password + +# Redis Cache (Production) +REDIS_URL=redis://localhost:6379/1 + +# Sentry Error Tracking (Optional) +SENTRY_DSN= + +# Google API Integration (Optional) +# GOOGLE_CREDENTIALS_PATH=/path/to/credentials.json +# GOOGLE_CALENDAR_ID=primary +# GOOGLE_IMPERSONATOR_EMAIL=admin@your-domain.com diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..5f8b5a8 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +.env +db.sqlite3 +data/ \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..76b6764 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Default command +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..520fe4c --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,10 @@ +""" +ASGI config for the project. +""" +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() \ No newline at end of file diff --git a/backend/config/settings/__init__.py b/backend/config/settings/__init__.py new file mode 100644 index 0000000..2784f0c --- /dev/null +++ b/backend/config/settings/__init__.py @@ -0,0 +1,13 @@ +""" +Settings initialization. +This module determines which settings file to use based on environment. +""" +import os + +# Set the environment variable to control which settings file is loaded +environment = os.environ.get('DJANGO_ENVIRONMENT', 'development') + +if environment == 'production': + from .production import * +else: + from .development import * \ No newline at end of file diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py new file mode 100644 index 0000000..cd2ba9a --- /dev/null +++ b/backend/config/settings/base.py @@ -0,0 +1,166 @@ +""" +Base settings for the application. +""" +import os +from datetime import timedelta +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# Application definition +INSTALLED_APPS = [ + # Django apps + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # Third-party apps + 'graphene_django', + 'corsheaders', + 'django_filters', + + # Local apps + 'backend.core', + 'backend.graphql_api', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = 'static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +# Media files +MEDIA_URL = 'media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# GraphQL settings +GRAPHENE = { + 'SCHEMA': 'backend.graphql_api.schema.schema', + "MIDDLEWARE": [ + "graphql_jwt.middleware.JSONWebTokenMiddleware", + ], +} + +# Authentication backends +AUTHENTICATION_BACKENDS = [ + 'graphql_jwt.backends.JSONWebTokenBackend', + 'django.contrib.auth.backends.ModelBackend', +] + +# JWT settings +GRAPHQL_JWT = { + 'JWT_VERIFY_EXPIRATION': True, + 'JWT_EXPIRATION_DELTA': timedelta(days=7), + 'JWT_REFRESH_EXPIRATION_DELTA': timedelta(days=30), +} + +# Create logs directory if it doesn't exist +logs_dir = os.path.join(BASE_DIR, 'logs') +if not os.path.exists(logs_dir): + os.makedirs(logs_dir) + +# Logging configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': os.path.join(logs_dir, 'django.log'), + 'formatter': 'verbose', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': True, + }, + 'core': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': True, + }, + 'graphql_api': { + 'handlers': ['console', 'file'], + 'level': 'INFO', + 'propagate': True, + }, + }, +} + diff --git a/backend/config/settings/development.py b/backend/config/settings/development.py new file mode 100644 index 0000000..a21ec98 --- /dev/null +++ b/backend/config/settings/development.py @@ -0,0 +1,42 @@ +""" +Development settings for the application. +""" +from .base import * +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('DEV_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['localhost', '127.0.0.1'] + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# CORS settings +CORS_ALLOW_ALL_ORIGINS = True # Only in development! + +# Email settings +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# GraphiQL settings +GRAPHENE['MIDDLEWARE'] += ['graphene_django.debug.DjangoDebugMiddleware'] + +# Show SQL queries in console +LOGGING['loggers']['django.db.backends'] = { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, +} \ No newline at end of file diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py new file mode 100644 index 0000000..66cd6af --- /dev/null +++ b/backend/config/settings/production.py @@ -0,0 +1,99 @@ +""" +Production settings for the application. +""" +from .base import * +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +import os +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = [ + os.environ.get('DJANGO_ALLOWED_HOST', 'example.com'), + 'www.example.com', # Update with your domain +] + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('DB_NAME', 'app_db'), + 'USER': os.environ.get('DB_USER', 'app_user'), + 'PASSWORD': os.environ.get('DB_PASSWORD', ''), + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'PORT': os.environ.get('DB_PORT', '5432'), + 'CONN_MAX_AGE': 600, # 10 minutes + } +} + +# Security settings +SECURE_HSTS_SECONDS = 31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# CORS settings +CORS_ALLOWED_ORIGINS = [ + 'https://example.com', + 'https://www.example.com', # Update with your frontend domain +] + +# Email settings +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.environ.get('EMAIL_HOST') +EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587)) +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD') +EMAIL_USE_TLS = True +DEFAULT_FROM_EMAIL = 'noreply@example.com' # Update with your email + +# Static files +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' + +# Configure Sentry for error tracking (optional) +try: + dsn = os.environ.get('SENTRY_DSN') + if dsn: # Only initialize if DSN is provided + sentry_sdk.init( + dsn=dsn, + integrations=[DjangoIntegration()], + traces_sample_rate=0.1, + send_default_pii=False + ) +except ImportError: + pass + + +# Cache settings +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': os.environ.get('REDIS_URL', 'redis://localhost:6379/1'), + } +} + +# Logging +log_path = '/var/log/django/django.log' +log_dir = os.path.dirname(log_path) +if os.path.exists(log_dir) and os.access(log_dir, os.W_OK): + LOGGING['handlers']['file']['filename'] = log_path + + +# Turn off DRF Browsable API in production +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + ) +} \ No newline at end of file diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..b7bc5da --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,19 @@ +""" +URL Configuration for the project. +""" +from django.contrib import admin +from django.urls import path +from django.conf import settings +from django.conf.urls.static import static +from django.views.decorators.csrf import csrf_exempt +from graphene_django.views import GraphQLView + +urlpatterns = [ + path('admin/', admin.site.urls), + path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=settings.DEBUG))), +] + +# Serve static and media files in development +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..d57e433 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,10 @@ +""" +WSGI config for the project. +""" +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() \ No newline at end of file diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/admin.py b/backend/core/admin.py new file mode 100644 index 0000000..ca926eb --- /dev/null +++ b/backend/core/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from backend.core.models import * + +admin.site.register(Account) +admin.site.register(Customer) +admin.site.register(Invoice) +admin.site.register(Labor) +admin.site.register(Profile) +admin.site.register(Project) +admin.site.register(Report) +admin.site.register(Revenue) +admin.site.register(Service) +admin.site.register(Schedule) \ No newline at end of file diff --git a/backend/core/apps.py b/backend/core/apps.py new file mode 100644 index 0000000..2496ef4 --- /dev/null +++ b/backend/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'backend.core' diff --git a/backend/core/commands/__init__.py b/backend/core/commands/__init__.py new file mode 100644 index 0000000..f2f0dce --- /dev/null +++ b/backend/core/commands/__init__.py @@ -0,0 +1,102 @@ +from backend.core.commands.base import Command, CommandResult +from backend.core.commands.customers.customers import CreateCustomerCommand, UpdateCustomerCommand, \ + DeleteCustomerCommand, MarkCustomerInactiveCommand +from backend.core.commands.accounts.accounts import CreateAccountCommand, UpdateAccountCommand, DeleteAccountCommand, \ + MarkAccountInactiveCommand, GetAccountRevenueCommand +from backend.core.commands.services.services import CreateServiceCommand, UpdateServiceCommand, DeleteServiceCommand, \ + CompleteServiceCommand, CancelServiceCommand, AssignTeamMembersCommand, GetServicesByDateRangeCommand +from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand +from backend.core.commands.projects.projects import CreateProjectCommand, UpdateProjectCommand, DeleteProjectCommand +from backend.core.commands.invoices.invoices import CreateInvoiceCommand, SendInvoiceCommand, MarkInvoicePaidCommand, \ + CancelInvoiceCommand, FilterInvoicesCommand +from backend.core.commands.labor.labor import CreateLaborCommand, UpdateLaborCommand, DeleteLaborCommand, \ + EndLaborCommand, CalculateLaborCostCommand +from backend.core.commands.profiles.profiles import CreateProfileCommand, UpdateProfileCommand, DeleteProfileCommand, \ + SearchProfilesCommand +from backend.core.commands.reports.reports import ( + CreateReportCommand, + UpdateReportCommand, + DeleteReportCommand, + GetTeamMemberReportsCommand, + GetTeamMemberActivityCommand, + GetTeamSummaryCommand +) +from backend.core.commands.revenues.revenues import ( + CreateRevenueCommand, + UpdateRevenueCommand, + DeleteRevenueCommand, + EndRevenueCommand, + GetRevenueByDateRangeCommand, + CalculateTotalRevenueCommand, + GetActiveRevenuesCommand +) +from backend.core.commands.schedules.schedules import ( + CreateScheduleCommand, + UpdateScheduleCommand, + DeleteScheduleCommand, + EndScheduleCommand, + GetActiveSchedulesCommand, + GenerateServicesCommand, + GetScheduleByAccountCommand, + SearchSchedulesCommand +) + +__all__ = [ + 'Command', + 'CommandResult', + 'CreateServiceCommand', + 'UpdateServiceCommand', + 'DeleteServiceCommand', + 'CreateProjectCommand', + 'UpdateProjectCommand', + 'DeleteProjectCommand', + 'CreateCustomerCommand', + 'UpdateCustomerCommand', + 'DeleteCustomerCommand', + 'MarkCustomerInactiveCommand', + 'CreateAccountCommand', + 'UpdateAccountCommand', + 'DeleteAccountCommand', + 'MarkAccountInactiveCommand', + 'GetAccountRevenueCommand', + 'CreateInvoiceCommand', + 'SendInvoiceCommand', + 'MarkInvoicePaidCommand', + 'CancelInvoiceCommand', + 'FilterInvoicesCommand', + 'CreateLaborCommand', + 'UpdateLaborCommand', + 'DeleteLaborCommand', + 'EndLaborCommand', + 'CalculateLaborCostCommand', + 'CreateProfileCommand', + 'UpdateProfileCommand', + 'DeleteProfileCommand', + 'SearchProfilesCommand', + 'CreateReportCommand', + 'UpdateReportCommand', + 'DeleteReportCommand', + 'GetTeamMemberReportsCommand', + 'GetTeamMemberActivityCommand', + 'GetTeamSummaryCommand', + 'CreateRevenueCommand', + 'UpdateRevenueCommand', + 'DeleteRevenueCommand', + 'EndRevenueCommand', + 'GetRevenueByDateRangeCommand', + 'CalculateTotalRevenueCommand', + 'GetActiveRevenuesCommand', + 'CreateScheduleCommand', + 'UpdateScheduleCommand', + 'DeleteScheduleCommand', + 'EndScheduleCommand', + 'GetActiveSchedulesCommand', + 'GenerateServicesCommand', + 'CompleteServiceCommand', + 'CancelServiceCommand', + 'AssignTeamMembersCommand', + 'GetServicesByDateRangeCommand', + 'GetScheduleByAccountCommand', + 'SearchSchedulesCommand', + 'BulkScheduleServicesCommand', +] diff --git a/backend/core/commands/accounts/__init__.py b/backend/core/commands/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/accounts/accounts.py b/backend/core/commands/accounts/accounts.py new file mode 100644 index 0000000..158df06 --- /dev/null +++ b/backend/core/commands/accounts/accounts.py @@ -0,0 +1,677 @@ +""" +Commands for account-related operations. +""" +from typing import Any, Dict, List, Optional +from datetime import datetime + +from backend.core.models.accounts.accounts import Account +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.customers.customers import CustomerRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_email, is_valid_phone, is_valid_date, + validate_required_fields, validate_model_exists +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateAccountCommand(Command): + """ + Command to create a new account. + """ + + def __init__( + self, + account_repo: AccountRepository, + customer_repo: CustomerRepository, + customer_id: str, + name: str, + street_address: str, + city: str, + state: str, + zip_code: str, + start_date: str, + primary_contact_first_name: str, # Changed from contact_first_name + primary_contact_last_name: str, # Changed from contact_last_name + primary_contact_phone: str, # Changed from contact_phone + primary_contact_email: str, # Changed from contact_email + secondary_contact_first_name: Optional[str] = None, # Added + secondary_contact_last_name: Optional[str] = None, # Added + secondary_contact_phone: Optional[str] = None, # Added + secondary_contact_email: Optional[str] = None, # Added + end_date: Optional[str] = None + ): + """ + Initialize the create account command. + + Args: + account_repo: Repository for account operations. + customer_repo: Repository for customer operations. + customer_id: ID of the customer this account belongs to. + name: Name of the account. + street_address: Street address of the account. + city: City of the account. + state: State of the account. + zip_code: ZIP code of the account. + primary_contact_first_name: First name of the primary contact. + primary_contact_last_name: Last name of the primary contact. + primary_contact_phone: Phone number of the primary contact. + primary_contact_email: Email of the primary contact. + secondary_contact_first_name: First name of the secondary contact (optional). + secondary_contact_last_name: Last name of the secondary contact (optional). + secondary_contact_phone: Phone number of the secondary contact (optional). + secondary_contact_email: Email of the secondary contact (optional). + start_date: Start date of the account (YYYY-MM-DD). + end_date: End date of the account (YYYY-MM-DD, optional). + """ + self.account_repo = account_repo + self.customer_repo = customer_repo + self.customer_id = customer_id + self.name = name + self.street_address = street_address + self.city = city + self.state = state + self.zip_code = zip_code + self.primary_contact_first_name = primary_contact_first_name + self.primary_contact_last_name = primary_contact_last_name + self.primary_contact_phone = primary_contact_phone + self.primary_contact_email = primary_contact_email + self.secondary_contact_first_name = secondary_contact_first_name + self.secondary_contact_last_name = secondary_contact_last_name + self.secondary_contact_phone = secondary_contact_phone + self.secondary_contact_email = secondary_contact_email + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the account creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + required_fields = [ + 'customer_id', 'name', 'street_address', 'city', 'state', 'zip_code', + 'primary_contact_first_name', 'primary_contact_last_name', + 'primary_contact_phone', 'primary_contact_email', 'start_date' + ] + + field_values = { + 'customer_id': self.customer_id, + 'name': self.name, + 'street_address': self.street_address, + 'city': self.city, + 'state': self.state, + 'zip_code': self.zip_code, + 'primary_contact_first_name': self.primary_contact_first_name, + 'primary_contact_last_name': self.primary_contact_last_name, + 'primary_contact_phone': self.primary_contact_phone, + 'primary_contact_email': self.primary_contact_email, + 'start_date': self.start_date + } + + missing_fields = validate_required_fields(field_values, required_fields) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate customer exists + if not errors and self.customer_id: + customer_validation = validate_model_exists( + self.customer_id, 'customer', self.customer_repo.get_by_id + ) + if not customer_validation['valid']: + errors.append(customer_validation['error']) + + if not errors and self.primary_contact_email and not is_valid_email(self.primary_contact_email): + errors.append("Invalid primary contact email format.") + + if not errors and self.primary_contact_phone and not is_valid_phone(self.primary_contact_phone): + errors.append("Invalid primary contact phone format.") + + if not errors and self.secondary_contact_email and not is_valid_email(self.secondary_contact_email): + errors.append("Invalid secondary contact email format.") + + if not errors and self.secondary_contact_phone and not is_valid_phone(self.secondary_contact_phone): + errors.append("Invalid secondary contact phone format.") + + # Validate date formats + if not errors and self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # Check if customer is active + if not errors: + customer = self.customer_repo.get_by_id(self.customer_id) + if customer and customer.end_date: + customer_end_date = parse_date(customer.end_date) + today = datetime.now().date() + if customer_end_date and customer_end_date < today: + errors.append(f"Cannot create account for inactive customer") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Account]: + """ + Execute the account creation command. + + Returns: + CommandResult[Account]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create account data + account_id = generate_uuid() + + # Create account data dictionary + account_data = { + 'id': account_id, + 'customer_id': self.customer_id, + 'name': self.name, + 'street_address': self.street_address, + 'city': self.city, + 'state': self.state, + 'zip_code': self.zip_code, + 'primary_contact_first_name': self.primary_contact_first_name, + 'primary_contact_last_name': self.primary_contact_last_name, + 'primary_contact_phone': self.primary_contact_phone, + 'primary_contact_email': self.primary_contact_email, + 'secondary_contact_first_name': self.secondary_contact_first_name, + 'secondary_contact_last_name': self.secondary_contact_last_name, + 'secondary_contact_phone': self.secondary_contact_phone, + 'secondary_contact_email': self.secondary_contact_email, + 'start_date': self.start_date, + 'end_date': self.end_date + } + + # Save to repository + created_account = self.account_repo.create(account_data) + + return CommandResult.success_result( + created_account, + f"Account {self.name} created successfully for customer {self.customer_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create account" + ) + + +class UpdateAccountCommand(Command): + """ + Command to update an existing account. + """ + + def __init__( + self, + account_repo: AccountRepository, + account_id: str, + name: Optional[str] = None, + street_address: Optional[str] = None, + city: Optional[str] = None, + state: Optional[str] = None, + zip_code: Optional[str] = None, + primary_contact_first_name: Optional[str] = None, + primary_contact_last_name: Optional[str] = None, + primary_contact_phone: Optional[str] = None, + primary_contact_email: Optional[str] = None, + secondary_contact_first_name: Optional[str] = None, + secondary_contact_last_name: Optional[str] = None, + secondary_contact_phone: Optional[str] = None, + secondary_contact_email: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the update account command. + + Args: + account_repo: Repository for account operations. + account_id: ID of the account to update. + name: New name for the account. + street_address: New street address for the account. + city: New city for the account. + state: New state for the account. + zip_code: New ZIP code for the account. + primary_contact_first_name: New first name for the primary contact. + primary_contact_last_name: New last name for the primary contact. + primary_contact_phone: New phone number for the primary contact. + primary_contact_email: New email for the primary contact. + secondary_contact_first_name: New first name for the secondary contact. + secondary_contact_last_name: New last name for the secondary contact. + secondary_contact_phone: New phone number for the secondary contact. + secondary_contact_email: New email for the secondary contact. + start_date: New start date for the account. + end_date: New end date for the account. + """ + self.account_repo = account_repo + self.account_id = account_id + self.name = name + self.street_address = street_address + self.city = city + self.state = state + self.zip_code = zip_code + self.primary_contact_first_name = primary_contact_first_name + self.primary_contact_last_name = primary_contact_last_name + self.primary_contact_phone = primary_contact_phone + self.primary_contact_email = primary_contact_email + self.secondary_contact_first_name = secondary_contact_first_name + self.secondary_contact_last_name = secondary_contact_last_name + self.secondary_contact_phone = secondary_contact_phone + self.secondary_contact_email = secondary_contact_email + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the account update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account exists + if not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + else: + account = self.account_repo.get_by_id(self.account_id) + if not account: + errors.append(f"Account with ID {self.account_id} not found") + + if not errors and self.primary_contact_email is not None and not is_valid_email(self.primary_contact_email): + errors.append("Invalid primary contact email format.") + + if not errors and self.primary_contact_phone is not None and not is_valid_phone(self.primary_contact_phone): + errors.append("Invalid primary contact phone format.") + + if not errors and self.secondary_contact_email is not None and not is_valid_email(self.secondary_contact_email): + errors.append("Invalid secondary contact email format.") + + if not errors and self.secondary_contact_phone is not None and not is_valid_phone(self.secondary_contact_phone): + errors.append("Invalid secondary contact phone format.") + + # Validate date formats if provided + if not errors and self.start_date is not None and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date is not None and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # If only updating end_date, validate it's after the existing start_date + if not errors and self.end_date and not self.start_date: + account = self.account_repo.get_by_id(self.account_id) + if account: + end = parse_date(self.end_date) + start = parse_date(account.start_date) + if end and start and start > end: + errors.append("End date must be after the existing start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Account]: + """ + Execute the account update command. + + Returns: + CommandResult[Account]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.name is not None: + update_data['name'] = self.name + + if self.street_address is not None: + update_data['street_address'] = self.street_address + + if self.city is not None: + update_data['city'] = self.city + + if self.state is not None: + update_data['state'] = self.state + + if self.zip_code is not None: + update_data['zip_code'] = self.zip_code + + if self.primary_contact_first_name is not None: + update_data['primary_contact_first_name'] = self.primary_contact_first_name + + if self.primary_contact_last_name is not None: + update_data['primary_contact_last_name'] = self.primary_contact_last_name + + if self.primary_contact_phone is not None: + update_data['primary_contact_phone'] = self.primary_contact_phone + + if self.primary_contact_email is not None: + update_data['primary_contact_email'] = self.primary_contact_email + + if self.secondary_contact_first_name is not None: + update_data['secondary_contact_first_name'] = self.secondary_contact_first_name + + if self.secondary_contact_last_name is not None: + update_data['secondary_contact_last_name'] = self.secondary_contact_last_name + + if self.secondary_contact_phone is not None: + update_data['secondary_contact_phone'] = self.secondary_contact_phone + + if self.secondary_contact_email is not None: + update_data['secondary_contact_email'] = self.secondary_contact_email + + if self.start_date is not None: + update_data['start_date'] = self.start_date + + if self.end_date is not None: + update_data['end_date'] = self.end_date + + # Update the account with the data dictionary + updated_account = self.account_repo.update(self.account_id, update_data) + + return CommandResult.success_result( + updated_account, + f"Account {self.account_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update account" + ) + + +class DeleteAccountCommand(Command): + """ + Command to delete an account. + """ + + def __init__( + self, + account_repo: AccountRepository, + account_id: str + ): + """ + Initialize the delete account command. + + Args: + account_repo: Repository for account operations. + account_id: ID of the account to delete. + """ + self.account_repo = account_repo + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the account deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account exists + if not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + else: + account = self.account_repo.get_by_id(self.account_id) + if not account: + errors.append(f"Account with ID {self.account_id} not found") + + # Check if account has associated services or projects + if not errors: + account_with_relations = self.account_repo.get_with_all_related(self.account_id) + if account_with_relations: + if hasattr(account_with_relations, 'services') and account_with_relations.services.exists(): + errors.append(f"Cannot delete account with associated services") + if hasattr(account_with_relations, 'projects') and account_with_relations.projects.exists(): + errors.append(f"Cannot delete account with associated projects") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the account deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the account + success = self.account_repo.delete(self.account_id) + + if success: + return CommandResult.success_result( + True, + f"Account {self.account_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete account", + f"Account {self.account_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete account" + ) + + +class MarkAccountInactiveCommand(Command): + """ + Command to mark an account as inactive. + """ + + def __init__( + self, + account_repo: AccountRepository, + account_id: str, + end_date: Optional[str] = None + ): + """ + Initialize the mark account inactive command. + + Args: + account_repo: Repository for account operations. + account_id: ID of the account to mark as inactive. + end_date: End date for the account (defaults to today if not provided). + """ + self.account_repo = account_repo + self.account_id = account_id + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the mark account inactive request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account exists + if not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + else: + account = self.account_repo.get_by_id(self.account_id) + if not account: + errors.append(f"Account with ID {self.account_id} not found") + elif account.end_date is not None: + errors.append(f"Account is already marked as inactive") + + # Validate end date format if provided + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate end date is after start date if provided + if not errors and self.end_date: + account = self.account_repo.get_by_id(self.account_id) + if account: + end = parse_date(self.end_date) + start = parse_date(account.start_date) + if end and start and start > end: + errors.append("End date must be after the start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Account]: + """ + Execute the mark account inactive command. + + Returns: + CommandResult[Account]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Mark the account as inactive + end_date = self.end_date or datetime.now().strftime('%Y-%m-%d') + updated_account = self.account_repo.update( + self.account_id, + {'end_date': end_date} + ) + + if updated_account: + return CommandResult.success_result( + updated_account, + f"Account {self.account_id} marked as inactive successfully" + ) + else: + return CommandResult.failure_result( + "Failed to mark account as inactive", + f"Account {self.account_id} could not be marked as inactive" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to mark account as inactive" + ) + + +class GetAccountRevenueCommand(Command): + """ + Command to get the revenue information for an account. + """ + + def __init__( + self, + account_repo: AccountRepository, + account_id: str + ): + """ + Initialize the get account revenue command. + + Args: + account_repo: Repository for account operations. + account_id: ID of the account to get revenue for. + """ + self.account_repo = account_repo + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the get account revenue request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account exists + if not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + else: + account = self.account_repo.get_by_id(self.account_id) + if not account: + errors.append(f"Account with ID {self.account_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Any]]: + """ + Execute the get account revenue command. + + Returns: + CommandResult[List[Any]]: Result of the command execution with revenue data. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Retrieve the account's revenue information + account_with_revenues = self.account_repo.get_with_revenues(self.account_id) + + if account_with_revenues and hasattr(account_with_revenues, 'revenues'): + revenues = list(account_with_revenues.revenues.all()) + return CommandResult.success_result( + revenues, + f"Retrieved revenue data for account {self.account_id}" + ) + else: + return CommandResult.failure_result( + "No revenue data found", + f"No revenue data found for account {self.account_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve account revenue data" + ) diff --git a/backend/core/commands/base.py b/backend/core/commands/base.py new file mode 100644 index 0000000..8486944 --- /dev/null +++ b/backend/core/commands/base.py @@ -0,0 +1,113 @@ +""" +Base classes for command pattern implementation. +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, TypeVar, Generic, Union + +# Type variable for the result of a command +T = TypeVar('T') + + +class Command(ABC): + """ + Base abstract class for all commands. + Commands encapsulate business logic and ensure data integrity. + """ + + @abstractmethod + def execute(self) -> Any: + """ + Execute the command and return the result. + Must be implemented by all command subclasses. + + Returns: + Any: The result of the command execution. + """ + pass + + def validate(self) -> Dict[str, Any]: + """ + Validate command data before execution. + Should be overridden by command subclasses. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + return {'is_valid': True, 'errors': None} + + +class CommandResult(Generic[T]): + """ + Represents the result of a command execution. + + Attributes: + success (bool): Whether the command was successful. + data (Optional[T]): The result data, if successful. + errors (Optional[List[str]]): List of errors, if unsuccessful. + message (Optional[str]): An optional message about the operation. + """ + + def __init__( + self, + success: bool = True, + data: Optional[T] = None, + errors: Optional[List[str]] = None, + message: Optional[str] = None + ): + """ + Initialize a new CommandResult. + + Args: + success: Whether the command was successful. + data: The result data, if successful. + errors: List of errors, if unsuccessful. + message: An optional message about the operation. + """ + self.success = success + self.data = data + self.errors = errors or [] + self.message = message + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the command result to a dictionary. + + Returns: + Dict[str, Any]: The command result as a dictionary. + """ + return { + 'success': self.success, + 'data': self.data, + 'errors': self.errors, + 'message': self.message + } + + @classmethod + def success_result(cls, data: T = None, message: Optional[str] = None) -> 'CommandResult[T]': + """ + Create a successful command result. + + Args: + data: The result data. + message: An optional success message. + + Returns: + CommandResult[T]: A successful command result. + """ + return cls(True, data, None, message) + + @classmethod + def failure_result(cls, errors: Union[str, List[str]], message: Optional[str] = None) -> 'CommandResult[T]': + """ + Create a failed command result. + + Args: + errors: Error or list of errors. + message: An optional failure message. + + Returns: + CommandResult[T]: A failed command result. + """ + if isinstance(errors, str): + errors = [errors] + return cls(False, None, errors, message) \ No newline at end of file diff --git a/backend/core/commands/customers/__init__.py b/backend/core/commands/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/customers/customers.py b/backend/core/commands/customers/customers.py new file mode 100644 index 0000000..c680495 --- /dev/null +++ b/backend/core/commands/customers/customers.py @@ -0,0 +1,629 @@ +""" +Commands for customer-related operations. +""" +from typing import Any, Dict, Optional, Union, List + +from graphene import UUID + +from backend.core.models.customers.customers import Customer +from backend.core.repositories.customers.customers import CustomerRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_email, is_valid_phone, is_valid_date, + validate_required_fields +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateCustomerCommand(Command): + """ + Command to create a new customer. + """ + + def __init__( + self, + customer_repo: CustomerRepository, + name: str, + primary_contact_first_name: str, + primary_contact_last_name: str, + primary_contact_phone: str, + primary_contact_email: str, + billing_contact_first_name: str, + billing_contact_last_name: str, + billing_street_address: str, + billing_city: str, + billing_state: str, + billing_zip_code: str, + billing_email: str, + billing_terms: str, + start_date: str, + secondary_contact_first_name: Optional[str] = None, + secondary_contact_last_name: Optional[str] = None, + secondary_contact_phone: Optional[str] = None, + secondary_contact_email: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the create customer command. + + Args: + customer_repo: Repository for customer operations. + name: Name of the customer. + primary_contact_first_name: First name of primary contact. + primary_contact_last_name: Last name of primary contact. + primary_contact_phone: Phone number of primary contact. + primary_contact_email: Email of primary contact. + billing_contact_first_name: First name of billing contact. + billing_contact_last_name: Last name of billing contact. + billing_street_address: Street address for billing. + billing_city: City for billing. + billing_state: State for billing. + billing_zip_code: ZIP code for billing. + billing_email: Email for billing. + billing_terms: Terms for billing. + start_date: Start date of customer relationship (YYYY-MM-DD). + secondary_contact_first_name: First name of secondary contact (optional). + secondary_contact_last_name: Last name of secondary contact (optional). + secondary_contact_phone: Phone number of secondary contact (optional). + secondary_contact_email: Email of secondary contact (optional). + end_date: End date of customer relationship (YYYY-MM-DD, optional). + """ + self.customer_repo = customer_repo + self.name = name + self.primary_contact_first_name = primary_contact_first_name + self.primary_contact_last_name = primary_contact_last_name + self.primary_contact_phone = primary_contact_phone + self.primary_contact_email = primary_contact_email + self.secondary_contact_first_name = secondary_contact_first_name + self.secondary_contact_last_name = secondary_contact_last_name + self.secondary_contact_phone = secondary_contact_phone + self.secondary_contact_email = secondary_contact_email + self.billing_contact_first_name = billing_contact_first_name + self.billing_contact_last_name = billing_contact_last_name + self.billing_street_address = billing_street_address + self.billing_city = billing_city + self.billing_state = billing_state + self.billing_zip_code = billing_zip_code + self.billing_email = billing_email + self.billing_terms = billing_terms + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the customer creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + required_fields = [ + 'name', 'primary_contact_first_name', 'primary_contact_last_name', + 'primary_contact_phone', 'primary_contact_email', + 'billing_contact_first_name', 'billing_contact_last_name', + 'billing_street_address', 'billing_city', 'billing_state', + 'billing_zip_code', 'billing_email', 'billing_terms', 'start_date' + ] + + field_values = { + 'name': self.name, + 'primary_contact_first_name': self.primary_contact_first_name, + 'primary_contact_last_name': self.primary_contact_last_name, + 'primary_contact_phone': self.primary_contact_phone, + 'primary_contact_email': self.primary_contact_email, + 'billing_contact_first_name': self.billing_contact_first_name, + 'billing_contact_last_name': self.billing_contact_last_name, + 'billing_street_address': self.billing_street_address, + 'billing_city': self.billing_city, + 'billing_state': self.billing_state, + 'billing_zip_code': self.billing_zip_code, + 'billing_email': self.billing_email, + 'billing_terms': self.billing_terms, + 'start_date': self.start_date + } + + missing_fields = validate_required_fields(field_values, required_fields) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate email formats + if not errors and self.primary_contact_email and not is_valid_email(self.primary_contact_email): + errors.append("Invalid primary contact email format.") + + if not errors and self.secondary_contact_email and not is_valid_email(self.secondary_contact_email): + errors.append("Invalid secondary contact email format.") + + if not errors and self.billing_email and not is_valid_email(self.billing_email): + errors.append("Invalid billing email format.") + + # Validate phone formats + if not errors and self.primary_contact_phone and not is_valid_phone(self.primary_contact_phone): + errors.append("Invalid primary contact phone format.") + + if not errors and self.secondary_contact_phone and not is_valid_phone(self.secondary_contact_phone): + errors.append("Invalid secondary contact phone format.") + + # Validate date formats + if not errors and self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Customer]: + """ + Execute the customer creation command. + + Returns: + CommandResult[Customer]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create customer data + customer_id = generate_uuid() + + # Create customer data dictionary + customer_data = { + 'id': customer_id, + 'name': self.name, + 'primary_contact_first_name': self.primary_contact_first_name, + 'primary_contact_last_name': self.primary_contact_last_name, + 'primary_contact_phone': self.primary_contact_phone, + 'primary_contact_email': self.primary_contact_email, + 'secondary_contact_first_name': self.secondary_contact_first_name, + 'secondary_contact_last_name': self.secondary_contact_last_name, + 'secondary_contact_phone': self.secondary_contact_phone, + 'secondary_contact_email': self.secondary_contact_email, + 'billing_contact_first_name': self.billing_contact_first_name, + 'billing_contact_last_name': self.billing_contact_last_name, + 'billing_street_address': self.billing_street_address, + 'billing_city': self.billing_city, + 'billing_state': self.billing_state, + 'billing_zip_code': self.billing_zip_code, + 'billing_email': self.billing_email, + 'billing_terms': self.billing_terms, + 'start_date': self.start_date, + 'end_date': self.end_date + } + + # Save to repository + created_customer = self.customer_repo.create(customer_data) + + return CommandResult.success_result( + created_customer, + f"Customer {self.name} created successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create customer" + ) + + +class UpdateCustomerCommand(Command): + """ + Command to update an existing customer. + """ + + def __init__( + self, + customer_repo: CustomerRepository, + id: UUID, + name: Optional[str] = None, + primary_contact_first_name: Optional[str] = None, + primary_contact_last_name: Optional[str] = None, + primary_contact_phone: Optional[str] = None, + primary_contact_email: Optional[str] = None, + secondary_contact_first_name: Optional[str] = None, + secondary_contact_last_name: Optional[str] = None, + secondary_contact_phone: Optional[str] = None, + secondary_contact_email: Optional[str] = None, + billing_contact_first_name: Optional[str] = None, + billing_contact_last_name: Optional[str] = None, + billing_street_address: Optional[str] = None, + billing_city: Optional[str] = None, + billing_state: Optional[str] = None, + billing_zip_code: Optional[str] = None, + billing_email: Optional[str] = None, + billing_terms: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the update customer command. + + Args: + customer_repo: Repository for customer operations. + id: ID of the customer to update. + name: New name for the customer. + primary_contact_first_name: New first name of primary contact. + primary_contact_last_name: New last name of primary contact. + primary_contact_phone: New phone number of primary contact. + primary_contact_email: New email of primary contact. + secondary_contact_first_name: New first name of secondary contact. + secondary_contact_last_name: New last name of secondary contact. + secondary_contact_phone: New phone number of secondary contact. + secondary_contact_email: New email of secondary contact. + billing_contact_first_name: New first name of billing contact. + billing_contact_last_name: New last name of billing contact. + billing_street_address: New street address for billing. + billing_city: New city for billing. + billing_state: New state for billing. + billing_zip_code: New ZIP code for billing. + billing_email: New email for billing. + billing_terms: New terms for billing. + start_date: New start date of customer relationship. + end_date: New end date of customer relationship. + """ + self.customer_repo = customer_repo + self.id = str(id) + self.name = name + self.primary_contact_first_name = primary_contact_first_name + self.primary_contact_last_name = primary_contact_last_name + self.primary_contact_phone = primary_contact_phone + self.primary_contact_email = primary_contact_email + self.secondary_contact_first_name = secondary_contact_first_name + self.secondary_contact_last_name = secondary_contact_last_name + self.secondary_contact_phone = secondary_contact_phone + self.secondary_contact_email = secondary_contact_email + self.billing_contact_first_name = billing_contact_first_name + self.billing_contact_last_name = billing_contact_last_name + self.billing_street_address = billing_street_address + self.billing_city = billing_city + self.billing_state = billing_state + self.billing_zip_code = billing_zip_code + self.billing_email = billing_email + self.billing_terms = billing_terms + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Union[bool, List[str]]]: + """ + Validate the customer update data. + + Returns: + Dict[str, Union[bool, List[str]]]: Validation result with 'is_valid' and 'errors'. + """ + errors = [] + + # Validate customer exists + if not is_valid_uuid(self.id): + errors.append("Invalid customer ID format") + else: + customer = self.customer_repo.get_by_id(self.id) + if not customer: + errors.append(f"Customer with ID {self.id} not found") + + # Validate email formats if provided + if not errors and self.primary_contact_email is not None and not is_valid_email(self.primary_contact_email): + errors.append("Invalid primary contact email format.") + + if not errors and self.secondary_contact_email is not None and not is_valid_email(self.secondary_contact_email): + errors.append("Invalid secondary contact email format.") + + if not errors and self.billing_email is not None and not is_valid_email(self.billing_email): + errors.append("Invalid billing email format.") + + # Validate phone formats if provided + if not errors and self.primary_contact_phone is not None and not is_valid_phone(self.primary_contact_phone): + errors.append("Invalid primary contact phone format.") + + if not errors and self.secondary_contact_phone is not None and not is_valid_phone(self.secondary_contact_phone): + errors.append("Invalid secondary contact phone format.") + + # Validate date formats if provided + if not errors and self.start_date is not None and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date is not None and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # If only updating end_date, validate it's after the existing start_date + if not errors and self.end_date and not self.start_date: + customer = self.customer_repo.get_by_id(self.id) + if customer: + end = parse_date(self.end_date) + if end and customer.start_date > end: + errors.append("End date must be after the existing start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Customer]: + """ + Execute the customer update command. + + Returns: + CommandResult[Customer]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.name is not None: + update_data['name'] = self.name + + if self.primary_contact_first_name is not None: + update_data['primary_contact_first_name'] = self.primary_contact_first_name + + if self.primary_contact_last_name is not None: + update_data['primary_contact_last_name'] = self.primary_contact_last_name + + if self.primary_contact_phone is not None: + update_data['primary_contact_phone'] = self.primary_contact_phone + + if self.primary_contact_email is not None: + update_data['primary_contact_email'] = self.primary_contact_email + + if self.secondary_contact_first_name is not None: + update_data['secondary_contact_first_name'] = self.secondary_contact_first_name + + if self.secondary_contact_last_name is not None: + update_data['secondary_contact_last_name'] = self.secondary_contact_last_name + + if self.secondary_contact_phone is not None: + update_data['secondary_contact_phone'] = self.secondary_contact_phone + + if self.secondary_contact_email is not None: + update_data['secondary_contact_email'] = self.secondary_contact_email + + if self.billing_contact_first_name is not None: + update_data['billing_contact_first_name'] = self.billing_contact_first_name + + if self.billing_contact_last_name is not None: + update_data['billing_contact_last_name'] = self.billing_contact_last_name + + if self.billing_street_address is not None: + update_data['billing_street_address'] = self.billing_street_address + + if self.billing_city is not None: + update_data['billing_city'] = self.billing_city + + if self.billing_state is not None: + update_data['billing_state'] = self.billing_state + + if self.billing_zip_code is not None: + update_data['billing_zip_code'] = self.billing_zip_code + + if self.billing_email is not None: + update_data['billing_email'] = self.billing_email + + if self.billing_terms is not None: + update_data['billing_terms'] = self.billing_terms + + if self.start_date is not None: + update_data['start_date'] = self.start_date + + if self.end_date is not None: + update_data['end_date'] = self.end_date + + # Update the customer with the data dictionary + updated_customer = self.customer_repo.update(self.id, update_data) + + return CommandResult.success_result( + updated_customer, + f"Customer {self.id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update customer" + ) + + +class DeleteCustomerCommand(Command): + """ + Command to delete a customer. + """ + + def __init__( + self, + customer_repo: CustomerRepository, + account_repo: AccountRepository, + customer_id: str + ): + """ + Initialize the delete customer command. + + Args: + customer_repo: Repository for customer operations. + account_repo: Repository for account operations. + customer_id: ID of the customer to delete. + """ + self.customer_repo = customer_repo + self.account_repo = account_repo + self.customer_id = customer_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the customer deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate customer exists + if not is_valid_uuid(self.customer_id): + errors.append("Invalid customer ID format") + else: + customer = self.customer_repo.get_by_id(self.customer_id) + if not customer: + errors.append(f"Customer with ID {self.customer_id} not found") + + # Check if customer has associated accounts + if not errors: + customer_with_accounts = self.customer_repo.get_with_accounts(self.customer_id) + # First check if customer_with_accounts is not None + if customer_with_accounts is not None: + # Now we can safely access the accounts attribute + if hasattr(customer_with_accounts, 'accounts') and customer_with_accounts.accounts.exists(): + errors.append(f"Cannot delete customer with associated accounts") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the customer deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the customer + success = self.customer_repo.delete(self.customer_id) + + if success: + return CommandResult.success_result( + True, + f"Customer {self.customer_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete customer", + f"Customer {self.customer_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete customer" + ) + + +class MarkCustomerInactiveCommand(Command): + """ + Command to mark a customer as inactive. + """ + + def __init__( + self, + customer_repo: CustomerRepository, + customer_id: str, + end_date: Optional[str] = None + ): + """ + Initialize the mark customer inactive command. + + Args: + customer_repo: Repository for customer operations. + customer_id: ID of the customer to mark as inactive. + end_date: End date for the customer relationship (defaults to today if not provided). + """ + self.customer_repo = customer_repo + self.customer_id = customer_id + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the mark customer inactive request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate customer exists + if not is_valid_uuid(self.customer_id): + errors.append("Invalid customer ID format") + else: + customer = self.customer_repo.get_by_id(self.customer_id) + if not customer: + errors.append(f"Customer with ID {self.customer_id} not found") + elif customer.end_date is not None: + errors.append(f"Customer is already marked as inactive") + + # Validate end date format if provided + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate end date is after start date if provided + if not errors and self.end_date: + customer = self.customer_repo.get_by_id(self.customer_id) + if customer: + end = parse_date(self.end_date) + if end and customer.start_date > end: + errors.append("End date must be after the start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Customer]: + """ + Execute the mark customer inactive command. + + Returns: + CommandResult[Customer]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Mark the customer as inactive + updated_customer = self.customer_repo.mark_inactive(self.customer_id) + + if updated_customer: + return CommandResult.success_result( + updated_customer, + f"Customer {self.customer_id} marked as inactive successfully" + ) + else: + return CommandResult.failure_result( + "Failed to mark customer as inactive", + f"Customer {self.customer_id} could not be marked as inactive" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to mark customer as inactive" + ) diff --git a/backend/core/commands/invoices/__init__.py b/backend/core/commands/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/invoices/invoices.py b/backend/core/commands/invoices/invoices.py new file mode 100644 index 0000000..5593f5b --- /dev/null +++ b/backend/core/commands/invoices/invoices.py @@ -0,0 +1,445 @@ +""" +Commands for invoice-related operations. +""" +from typing import Any, Dict, List, Optional, Union +from datetime import date + +from backend.core.models.invoices.invoices import Invoice +from backend.core.repositories.invoices.invoices import InvoiceRepository +from backend.core.repositories.customers.customers import CustomerRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_date, validate_required_fields, + validate_model_exists +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateInvoiceCommand(Command): + """ + Command to create a new invoice. + """ + + def __init__( + self, + invoice_repo: InvoiceRepository, + customer_repo: CustomerRepository, + customer_id: str, + invoice_date: str, + account_ids: Optional[List[str]] = None, + project_ids: Optional[List[str]] = None, + total_amount: Optional[float] = None, + status: str = 'draft' + ): + """ + Initialize the create invoice command. + """ + self.invoice_repo = invoice_repo + self.customer_repo = customer_repo + self.customer_id = customer_id + self.date = invoice_date + self.account_ids = account_ids or [] + self.project_ids = project_ids or [] + self.total_amount = total_amount or 0 + self.status = status + + def validate(self) -> Dict[str, Any]: + """ + Validate the invoice creation data. + """ + errors = [] + + # Check required fields + missing_fields = validate_required_fields( + {'customer_id': self.customer_id, 'date': self.date}, + ['customer_id', 'date'] + ) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate customer exists + if not errors and self.customer_id: + customer_validation = validate_model_exists( + self.customer_id, 'customer', self.customer_repo.get_by_id + ) + if not customer_validation['valid']: + errors.append(customer_validation['error']) + + # Validate date format + if not errors and self.date and not is_valid_date(self.date): + errors.append("Invalid date format. Use YYYY-MM-DD.") + + # Validate status + valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'] + if not errors and self.status not in valid_statuses: + errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + # If no accounts or projects are specified, warn about empty invoice + if not errors and not self.account_ids and not self.project_ids: + errors.append("Invoice must include at least one account or project") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Invoice]: + """ + Execute the invoice creation command. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create invoice data + invoice_id = generate_uuid() + + # Setup invoice data + invoice_data = { + 'id': invoice_id, + 'customer_id': self.customer_id, + 'date': self.date, + 'status': self.status, + 'total_amount': self.total_amount + } + + # Create invoice with related items + created_invoice = self.invoice_repo.create_with_items( + invoice_data, + self.account_ids, + self.project_ids + ) + + return CommandResult.success_result( + created_invoice, + f"Invoice created successfully for customer {self.customer_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create invoice" + ) + + +class SendInvoiceCommand(Command): + """ + Command to send an invoice. + """ + + def __init__( + self, + invoice_repo: InvoiceRepository, + invoice_id: str + ): + """ + Initialize the send invoice command. + """ + self.invoice_repo = invoice_repo + self.invoice_id = invoice_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the invoice sending request. + """ + errors = [] + + # Validate invoice exists + if not is_valid_uuid(self.invoice_id): + errors.append("Invalid invoice ID format") + else: + invoice = self.invoice_repo.get_by_id(self.invoice_id) + if not invoice: + errors.append(f"Invoice with ID {self.invoice_id} not found") + + # Check if invoice is in draft status + if not errors and invoice.status != 'draft': + errors.append(f"Only invoices in 'draft' status can be sent. Current status: {invoice.status}") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Invoice]: + """ + Execute the send invoice command. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Update invoice status to 'sent' + updated_invoice = self.invoice_repo.update_status(self.invoice_id, 'sent') + + if updated_invoice: + return CommandResult.success_result( + updated_invoice, + f"Invoice {self.invoice_id} sent successfully" + ) + else: + return CommandResult.failure_result( + "Failed to send invoice", + f"Invoice {self.invoice_id} could not be sent" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to send invoice" + ) + + +class MarkInvoicePaidCommand(Command): + """ + Command to mark an invoice as paid. + """ + + def __init__( + self, + invoice_repo: InvoiceRepository, + invoice_id: str, + payment_type: str, + date_paid: Optional[str] = None + ): + """ + Initialize the mark invoice paid command. + """ + self.invoice_repo = invoice_repo + self.invoice_id = invoice_id + self.payment_type = payment_type + self.date_paid = date_paid + + def validate(self) -> Dict[str, Any]: + """ + Validate the mark invoice paid request. + """ + errors = [] + + # Validate invoice exists + if not is_valid_uuid(self.invoice_id): + errors.append("Invalid invoice ID format") + else: + invoice = self.invoice_repo.get_by_id(self.invoice_id) + if not invoice: + errors.append(f"Invoice with ID {self.invoice_id} not found") + + # Check if invoice can be marked as paid + if not errors and invoice.status not in ['sent', 'overdue']: + errors.append( + f"Only invoices in 'sent' or 'overdue' status can be marked as paid. Current status: {invoice.status}") + + # Validate payment type + valid_payment_types = ['check', 'credit_card', 'bank_transfer', 'cash'] + if not errors and self.payment_type not in valid_payment_types: + errors.append(f"Invalid payment type. Must be one of: {', '.join(valid_payment_types)}") + + # Validate date_paid format if provided + if not errors and self.date_paid and not is_valid_date(self.date_paid): + errors.append("Invalid date_paid format. Use YYYY-MM-DD.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Invoice]: + """ + Execute the mark invoice paid command. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # If date_paid is provided, update with that date + if self.date_paid: + update_data = { + 'status': 'paid', + 'date_paid': self.date_paid, + 'payment_type': self.payment_type + } + updated_invoice = self.invoice_repo.update(self.invoice_id, update_data) + else: + # Use the repository method to mark as paid with today's date + updated_invoice = self.invoice_repo.mark_as_paid(self.invoice_id, self.payment_type) + + return CommandResult.success_result( + updated_invoice, + f"Invoice {self.invoice_id} marked as paid successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to mark invoice as paid" + ) + + +class CancelInvoiceCommand(Command): + """ + Command to cancel an invoice. + """ + + def __init__( + self, + invoice_repo: InvoiceRepository, + invoice_id: str + ): + """ + Initialize the cancel invoice command. + """ + self.invoice_repo = invoice_repo + self.invoice_id = invoice_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the invoice cancellation request. + """ + errors = [] + + # Validate invoice exists + if not is_valid_uuid(self.invoice_id): + errors.append("Invalid invoice ID format") + else: + invoice = self.invoice_repo.get_by_id(self.invoice_id) + if not invoice: + errors.append(f"Invoice with ID {self.invoice_id} not found") + + # Check if invoice can be cancelled + if not errors and invoice.status == 'paid': + errors.append("Paid invoices cannot be cancelled") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Invoice]: + """ + Execute the cancel invoice command. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Update invoice status to 'cancelled' + updated_invoice = self.invoice_repo.update_status(self.invoice_id, 'cancelled') + + return CommandResult.success_result( + updated_invoice, + f"Invoice {self.invoice_id} cancelled successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to cancel invoice" + ) + + +class FilterInvoicesCommand(Command): + """ + Command to filter invoices by multiple criteria. + """ + + def __init__( + self, + invoice_repo: InvoiceRepository, + customer_id: Optional[str] = None, + status: Optional[str] = None, + date_from: Optional[Union[str, date]] = None, + date_to: Optional[Union[str, date]] = None, + account_id: Optional[str] = None, + project_id: Optional[str] = None + ): + """ + Initialize the filter invoices command. + """ + self.invoice_repo = invoice_repo + self.customer_id = customer_id + self.status = status + + # Convert string dates to date objects if needed + if isinstance(date_from, str): + self.date_from = parse_date(date_from) + else: + self.date_from = date_from + + if isinstance(date_to, str): + self.date_to = parse_date(date_to) + else: + self.date_to = date_to + + self.account_id = account_id + self.project_id = project_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the filter invoices request. + """ + errors = [] + + # Validate UUID formats + if self.customer_id and not is_valid_uuid(self.customer_id): + errors.append("Invalid customer ID format") + + if self.account_id and not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + + if self.project_id and not is_valid_uuid(self.project_id): + errors.append("Invalid project ID format") + + # Validate status if provided + if self.status: + valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'] + if self.status not in valid_statuses: + errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + # Validate date range if both provided + if self.date_from and self.date_to and self.date_from > self.date_to: + errors.append("Start date must be before end date") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Invoice]]: + """ + Execute the filter invoices command. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Filter invoices + filtered_invoices = self.invoice_repo.filter_invoices( + customer_id=self.customer_id, + status=self.status, + date_from=self.date_from, + date_to=self.date_to, + account_id=self.account_id, + project_id=self.project_id + ) + + return CommandResult.success_result( + list(filtered_invoices), + f"Found {filtered_invoices.count()} matching invoices" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to filter invoices" + ) \ No newline at end of file diff --git a/backend/core/commands/labor/__init__.py b/backend/core/commands/labor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/labor/labor.py b/backend/core/commands/labor/labor.py new file mode 100644 index 0000000..b0b57dc --- /dev/null +++ b/backend/core/commands/labor/labor.py @@ -0,0 +1,550 @@ +""" +Commands for labor-related operations. +""" +from typing import Any, Dict, Optional, Union +from decimal import Decimal +from backend.core.models.labor.labor import Labor +from backend.core.repositories.labor.labor import LaborRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_date, validate_required_fields, + validate_model_exists, validate_decimal_amount +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateLaborCommand(Command): + """ + Command to create a new labor record. + """ + + def __init__( + self, + labor_repo: LaborRepository, + account_repo: AccountRepository, + account_id: str, + amount: Union[float, str, Decimal], + start_date: str, + end_date: Optional[str] = None + ): + """ + Initialize the create labor command. + + Args: + labor_repo: Repository for labor operations. + account_repo: Repository for account operations. + account_id: ID of the account the labor is for. + amount: Labor cost amount. + start_date: Start date of the labor (YYYY-MM-DD). + end_date: Optional end date of the labor (YYYY-MM-DD). + """ + self.labor_repo = labor_repo + self.account_repo = account_repo + self.account_id = account_id + self.amount = amount + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the labor creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + missing_fields = validate_required_fields( + { + 'account_id': self.account_id, + 'amount': self.amount, + 'start_date': self.start_date + }, + ['account_id', 'amount', 'start_date'] + ) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate account exists + if not errors and self.account_id: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate amount + if not errors: + amount_validation = validate_decimal_amount(self.amount, 'amount') + if not amount_validation['valid']: + errors.append(amount_validation['error']) + + # Validate date formats + if not errors and self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Labor]: + """ + Execute the labor creation command. + + Returns: + CommandResult[Labor]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create labor data + labor_id = generate_uuid() + + # Create labor data dictionary + labor_data = { + 'id': labor_id, + 'account_id': self.account_id, + 'amount': self.amount, + 'start_date': self.start_date, + 'end_date': self.end_date + } + + # Save to repository + created_labor = self.labor_repo.create(labor_data) + + return CommandResult.success_result( + created_labor, + f"Labor record for account {self.account_id} created successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create labor record" + ) + + +class UpdateLaborCommand(Command): + """ + Command to update an existing labor record. + """ + + def __init__( + self, + labor_repo: LaborRepository, + account_repo: AccountRepository, + labor_id: str, + amount: Optional[Union[float, str, Decimal]] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the update labor command. + + Args: + labor_repo: Repository for labor operations. + account_repo: Repository for account operations. + labor_id: ID of the labor record to update. + amount: New labor cost amount. + start_date: New start date of the labor. + end_date: New end date of the labor. + """ + self.labor_repo = labor_repo + self.account_repo = account_repo + self.labor_id = labor_id + self.amount = amount + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the labor update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate labor exists + if not is_valid_uuid(self.labor_id): + errors.append("Invalid labor ID format") + else: + labor = self.labor_repo.get_by_id(self.labor_id) + if not labor: + errors.append(f"Labor record with ID {self.labor_id} not found") + + # Validate amount if provided + if not errors and self.amount is not None: + amount_validation = validate_decimal_amount(self.amount, 'amount') + if not amount_validation['valid']: + errors.append(amount_validation['error']) + + # Validate date formats if provided + if not errors and self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # If only updating end_date, validate it's after the existing start_date + if not errors and self.end_date and not self.start_date: + labor = self.labor_repo.get_by_id(self.labor_id) + if labor: + end = parse_date(self.end_date) + if end and labor.start_date > end: + errors.append("End date must be after the existing start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Labor]: + """ + Execute the labor update command. + + Returns: + CommandResult[Labor]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.amount is not None: + update_data['amount'] = self.amount + + if self.start_date is not None: + update_data['start_date'] = self.start_date + + if self.end_date is not None: + update_data['end_date'] = self.end_date + + # Update the labor record with the data dictionary + updated_labor = self.labor_repo.update(self.labor_id, update_data) + + return CommandResult.success_result( + updated_labor, + f"Labor record {self.labor_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update labor record" + ) + + +class DeleteLaborCommand(Command): + """ + Command to delete a labor record. + """ + + def __init__(self, labor_repo: LaborRepository, labor_id: str): + """ + Initialize the delete labor command. + + Args: + labor_repo: Repository for labor operations. + labor_id: ID of the labor record to delete. + """ + self.labor_repo = labor_repo + self.labor_id = labor_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the labor deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate labor exists + if not is_valid_uuid(self.labor_id): + errors.append("Invalid labor ID format") + else: + labor = self.labor_repo.get_by_id(self.labor_id) + if not labor: + errors.append(f"Labor record with ID {self.labor_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the labor deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the labor record + success = self.labor_repo.delete(self.labor_id) + + if success: + return CommandResult.success_result( + True, + f"Labor record {self.labor_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete labor record", + f"Labor record {self.labor_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete labor record" + ) + + +class EndLaborCommand(Command): + """ + Command to end a labor record by setting its end date to today. + """ + + def __init__(self, labor_repo: LaborRepository, labor_id: str, end_date: Optional[str] = None): + """ + Initialize the end labor command. + + Args: + labor_repo: Repository for labor operations. + labor_id: ID of the labor record to end. + end_date: Optional specific end date (defaults to today if not provided). + """ + self.labor_repo = labor_repo + self.labor_id = labor_id + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the labor end request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate labor exists + if not is_valid_uuid(self.labor_id): + errors.append("Invalid labor ID format") + else: + labor = self.labor_repo.get_by_id(self.labor_id) + if not labor: + errors.append(f"Labor record with ID {self.labor_id} not found") + elif labor.end_date is not None: + errors.append(f"Labor record is already ended") + + # Validate end date format if provided + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate end date is after start date if provided + if not errors and self.end_date: + labor = self.labor_repo.get_by_id(self.labor_id) + if labor: + end = parse_date(self.end_date) + if end and labor.start_date > end: + errors.append("End date must be after the start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Labor]: + """ + Execute the end labor command. + + Returns: + CommandResult[Labor]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # End the labor record + if self.end_date: + # Use provided end date + updated_labor = self.labor_repo.update(self.labor_id, {'end_date': self.end_date}) + else: + # Use repository method that sets end date to today + updated_labor = self.labor_repo.end_labor(self.labor_id) + + if updated_labor: + return CommandResult.success_result( + updated_labor, + f"Labor record {self.labor_id} ended successfully" + ) + else: + return CommandResult.failure_result( + "Failed to end labor record", + f"Labor record {self.labor_id} could not be ended" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to end labor record" + ) + + +class CalculateLaborCostCommand(Command): + """ + Command to calculate labor cost for an account or all accounts within a date range. + """ + + def __init__( + self, + labor_repo: LaborRepository, + account_repo: AccountRepository, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + account_id: Optional[str] = None + ): + """ + Initialize the calculate labor cost command. + + Args: + labor_repo: Repository for labor operations. + account_repo: Repository for account operations. + start_date: Optional start date for the period. + end_date: Optional end date for the period. + account_id: Optional account ID to filter by. + """ + self.labor_repo = labor_repo + self.account_repo = account_repo + self.start_date = start_date + self.end_date = end_date + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the labor cost calculation request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account exists if provided + # Validate account exists if provided + if self.account_id: + if not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + else: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate date formats if provided + if self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[float]: + """ + Execute the labor cost calculation command. + + Returns: + CommandResult[float]: Result of the command execution with the calculated cost. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Parse dates if provided + start_date_obj = parse_date(self.start_date) if self.start_date else None + end_date_obj = parse_date(self.end_date) if self.end_date else None + + # Calculate labor cost + total_cost = self.labor_repo.get_total_labor_cost( + account_id=self.account_id, + start_date=start_date_obj, + end_date=end_date_obj + ) + + # Create a descriptive message + message = "Total labor cost" + if self.account_id: + account = self.account_repo.get_by_id(self.account_id) + if account: + message += f" for account '{account.name}'" + + if self.start_date and self.end_date: + message += f" from {self.start_date} to {self.end_date}" + elif self.start_date: + message += f" from {self.start_date}" + elif self.end_date: + message += f" until {self.end_date}" + + message += f": ${total_cost:.2f}" + + return CommandResult.success_result( + total_cost, + message + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to calculate labor cost" + ) diff --git a/backend/core/commands/profiles/__init__.py b/backend/core/commands/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/profiles/profiles.py b/backend/core/commands/profiles/profiles.py new file mode 100644 index 0000000..67a492b --- /dev/null +++ b/backend/core/commands/profiles/profiles.py @@ -0,0 +1,429 @@ +""" +Commands for profile-related operations. +""" +from typing import Any, Dict, Optional +from django.contrib.auth.models import User + +from backend.core.models.profiles.profiles import Profile +from backend.core.repositories.profiles.profiles import ProfileRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_email, is_valid_phone, + validate_required_fields +) +from backend.core.utils.helpers import generate_uuid +from backend.core.commands.base import Command, CommandResult + + +class CreateProfileCommand(Command): + """ + Command to create a new profile. + """ + + def __init__( + self, + profile_repo: ProfileRepository, + user: User, + first_name: str, + last_name: str, + primary_phone: str, + email: str, + role: str = 'team_member', + secondary_phone: Optional[str] = None + ): + """ + Initialize the create profile command. + + Args: + profile_repo: Repository for profile operations. + user: The Django User object to associate with the profile. + first_name: First name of the profile. + last_name: Last name of the profile. + primary_phone: Primary phone number. + email: Email address. + role: Role of the profile ('admin', 'team_leader', 'team_member'). + secondary_phone: Optional secondary phone number. + """ + self.profile_repo = profile_repo + self.user = user + self.first_name = first_name + self.last_name = last_name + self.primary_phone = primary_phone + self.email = email + self.role = role + self.secondary_phone = secondary_phone + + def validate(self) -> Dict[str, Any]: + """ + Validate the profile creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + missing_fields = validate_required_fields( + { + 'user': self.user, + 'first_name': self.first_name, + 'last_name': self.last_name, + 'primary_phone': self.primary_phone, + 'email': self.email + }, + ['user', 'first_name', 'last_name', 'primary_phone', 'email'] + ) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate email format + if not errors and self.email and not is_valid_email(self.email): + errors.append("Invalid email format.") + + # Validate phone formats + if not errors and self.primary_phone and not is_valid_phone(self.primary_phone): + errors.append("Invalid primary phone format.") + + if not errors and self.secondary_phone and not is_valid_phone(self.secondary_phone): + errors.append("Invalid secondary phone format.") + + # Validate role + valid_roles = ['admin', 'team_leader', 'team_member'] + if not errors and self.role not in valid_roles: + errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}") + + # Check if user already has a profile + if not errors and self.profile_repo.get_by_user(self.user): + errors.append(f"User already has a profile") + + # Check if email is already in use + if not errors and self.profile_repo.get_by_email(self.email): + errors.append(f"Email is already in use") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Profile]: + """ + Execute the profile creation command. + + Returns: + CommandResult[Profile]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create profile data + profile_id = generate_uuid() + + # Create profile data dictionary + profile_data = { + 'id': profile_id, + 'user': self.user, + 'first_name': self.first_name, + 'last_name': self.last_name, + 'primary_phone': self.primary_phone, + 'secondary_phone': self.secondary_phone, + 'email': self.email, + 'role': self.role + } + + # Save to repository + created_profile = self.profile_repo.create(profile_data) + + return CommandResult.success_result( + created_profile, + f"Profile for {self.first_name} {self.last_name} created successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create profile" + ) + + +class UpdateProfileCommand(Command): + """ + Command to update an existing profile. + """ + + def __init__( + self, + profile_repo: ProfileRepository, + profile_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + primary_phone: Optional[str] = None, + email: Optional[str] = None, + role: Optional[str] = None, + secondary_phone: Optional[str] = None + ): + """ + Initialize the update profile command. + + Args: + profile_repo: Repository for profile operations. + profile_id: ID of the profile to update. + first_name: New first name. + last_name: New last name. + primary_phone: New primary phone number. + email: New email address. + role: New role. + secondary_phone: New secondary phone number. + """ + self.profile_repo = profile_repo + self.profile_id = profile_id + self.first_name = first_name + self.last_name = last_name + self.primary_phone = primary_phone + self.email = email + self.role = role + self.secondary_phone = secondary_phone + + def validate(self) -> Dict[str, Any]: + """ + Validate the profile update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate profile exists + if not is_valid_uuid(self.profile_id): + errors.append("Invalid profile ID format") + else: + profile = self.profile_repo.get_by_id(self.profile_id) + if not profile: + errors.append(f"Profile with ID {self.profile_id} not found") + + # Validate email format if provided + if not errors and self.email is not None and not is_valid_email(self.email): + errors.append("Invalid email format.") + + # Validate phone formats if provided + if not errors and self.primary_phone is not None and not is_valid_phone(self.primary_phone): + errors.append("Invalid primary phone format.") + + if not errors and self.secondary_phone is not None and not is_valid_phone(self.secondary_phone): + errors.append("Invalid secondary phone format.") + + # Validate role if provided + valid_roles = ['admin', 'team_leader', 'team_member'] + if not errors and self.role is not None and self.role not in valid_roles: + errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}") + + # Check if email is already in use by another profile + if not errors and self.email is not None: + existing_profile = self.profile_repo.get_by_email(self.email) + if existing_profile and str(existing_profile.id) != self.profile_id: + errors.append(f"Email is already in use by another profile") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Profile]: + """ + Execute the profile update command. + + Returns: + CommandResult[Profile]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.first_name is not None: + update_data['first_name'] = self.first_name + + if self.last_name is not None: + update_data['last_name'] = self.last_name + + if self.primary_phone is not None: + update_data['primary_phone'] = self.primary_phone + + if self.secondary_phone is not None: + update_data['secondary_phone'] = self.secondary_phone + + if self.email is not None: + update_data['email'] = self.email + + if self.role is not None: + update_data['role'] = self.role + + # Update the profile with the data dictionary + updated_profile = self.profile_repo.update(self.profile_id, update_data) + + return CommandResult.success_result( + updated_profile, + f"Profile {self.profile_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update profile" + ) + + +class DeleteProfileCommand(Command): + """ + Command to delete a profile. + """ + + def __init__(self, profile_repo: ProfileRepository, profile_id: str): + """ + Initialize the delete profile command. + + Args: + profile_repo: Repository for profile operations. + profile_id: ID of the profile to delete. + """ + self.profile_repo = profile_repo + self.profile_id = profile_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the profile deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate profile exists + if not is_valid_uuid(self.profile_id): + errors.append("Invalid profile ID format") + else: + profile = self.profile_repo.get_by_id(self.profile_id) + if not profile: + errors.append(f"Profile with ID {self.profile_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the profile deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the profile + success = self.profile_repo.delete(self.profile_id) + + if success: + return CommandResult.success_result( + True, + f"Profile {self.profile_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete profile", + f"Profile {self.profile_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete profile" + ) + + +class SearchProfilesCommand(Command): + """ + Command to search for profiles.py. + """ + + def __init__( + self, + profile_repo: ProfileRepository, + search_term: str, + role: Optional[str] = None + ): + """ + Initialize the search profiles.py command. + + Args: + profile_repo: Repository for profile operations. + search_term: The search term to look for. + role: Optional role to filter by. + """ + self.profile_repo = profile_repo + self.search_term = search_term + self.role = role + + def validate(self) -> Dict[str, Any]: + """ + Validate the profile search request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate role if provided + valid_roles = ['admin', 'team_leader', 'team_member'] + if self.role is not None and self.role not in valid_roles: + errors.append(f"Invalid role. Must be one of: {', '.join(valid_roles)}") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[list]: + """ + Execute the profile search command. + + Returns: + CommandResult[list]: Result of the command execution with list of matching profiles.py. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Search for profiles.py + results = self.profile_repo.search(self.search_term) + + # Filter by role if provided + if self.role: + results = results.filter(role=self.role) + + # Convert QuerySet to list + profiles = list(results) + + return CommandResult.success_result( + profiles, + f"Found {len(profiles)} matching profiles.py" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to search profiles.py" + ) diff --git a/backend/core/commands/projects/__init__.py b/backend/core/commands/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/projects/projects.py b/backend/core/commands/projects/projects.py new file mode 100644 index 0000000..3adc22a --- /dev/null +++ b/backend/core/commands/projects/projects.py @@ -0,0 +1,444 @@ +""" +Commands for project-related operations. +""" +from typing import Any, Dict, List, Optional, Union +from decimal import Decimal + +from backend.core.models.projects.projects import Project +from backend.core.repositories.projects.projects import ProjectRepository +from backend.core.repositories.customers.customers import CustomerRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.profiles.profiles import ProfileRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_date, validate_required_fields, + validate_model_exists, validate_decimal_amount +) +from backend.core.utils.helpers import generate_uuid +from backend.core.commands.base import Command, CommandResult + + +class CreateProjectCommand(Command): + """ + Command to create a new project. + """ + + def __init__( + self, + project_repo: ProjectRepository, + customer_repo: CustomerRepository, + account_repo: AccountRepository, + profile_repo: ProfileRepository, + customer_id: str, + date: str, + labor: Union[float, str, Decimal], + status: str = 'planned', + account_id: Optional[str] = None, + team_member_ids: Optional[List[str]] = None, + notes: Optional[str] = None, + amount: Optional[Union[float, str, Decimal]] = None, + ): + """ + Initialize the create project command. + + Args: + project_repo: Repository for project operations. + customer_repo: Repository for customer operations. + account_repo: Repository for account operations. + profile_repo: Repository for profile operations. + customer_id: ID of the customer the project is for. + date: Date of the project (YYYY-MM-DD). + labor: Labor cost for the project. + status: Status of the project ('planned', 'in_progress', 'completed', 'cancelled'). + account_id: Optional ID of the account the project is for. + team_member_ids: List of profile IDs for team members assigned to the project. + notes: Additional notes about the project. + amount: Billing amount for the project. + """ + self.project_repo = project_repo + self.customer_repo = customer_repo + self.account_repo = account_repo + self.profile_repo = profile_repo + self.customer_id = customer_id + self.date = date + self.labor = labor + self.status = status + self.account_id = account_id + self.team_member_ids = team_member_ids or [] + self.notes = notes + self.amount = amount or labor # Default billing amount to labor cost if not specified + + def validate(self) -> Dict[str, Any]: + """ + Validate the project creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + missing_fields = validate_required_fields( + { + 'customer_id': self.customer_id, + 'date': self.date, + 'labor': self.labor + }, + ['customer_id', 'date', 'labor'] + ) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate customer exists + if not errors and self.customer_id: + customer_validation = validate_model_exists( + self.customer_id, 'customer', self.customer_repo.get_by_id + ) + if not customer_validation['valid']: + errors.append(customer_validation['error']) + + # Validate account exists if provided + if not errors and self.account_id: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate date format + if not errors and self.date and not is_valid_date(self.date): + errors.append("Invalid date format. Use YYYY-MM-DD.") + + # Validate labor amount + if not errors: + labor_validation = validate_decimal_amount(self.labor, 'labor') + if not labor_validation['valid']: + errors.append(labor_validation['error']) + + # Validate billing amount if provided + if not errors and self.amount is not None: + amount_validation = validate_decimal_amount(self.amount, 'amount') + if not amount_validation['valid']: + errors.append(amount_validation['error']) + + # Validate status + valid_statuses = ['planned', 'in_progress', 'completed', 'cancelled'] + if not errors and self.status not in valid_statuses: + errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + # Validate team member IDs + if not errors and self.team_member_ids: + for member_id in self.team_member_ids: + if not is_valid_uuid(member_id): + errors.append(f"Invalid team member ID format: {member_id}") + elif not self.profile_repo.get_by_id(member_id): + errors.append(f"Team member with ID {member_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Project]: + """ + Execute the project creation command. + + Returns: + CommandResult[Project]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create project data + project_id = generate_uuid() + + # Normalize decimal values + labor = float(self.labor) + amount = float(self.amount) if self.amount is not None else labor + + # Create project data dictionary instead of Project object + project_data = { + 'id': project_id, + 'customer_id': self.customer_id, + 'account_id': self.account_id, + 'date': self.date, + 'status': self.status, + 'notes': self.notes, + 'labor': labor, + 'amount': amount + } + + # Save to repository and handle team members + if self.team_member_ids: + # If there's a create_with_team_members method like in ServiceRepository + created_project = self.project_repo.create_with_team_members( + project_data, + self.team_member_ids + ) + else: + # Otherwise create the project first, then assign team members + created_project = self.project_repo.create(project_data) + + # Assign team members if any + if self.team_member_ids: + team_members = [] + for member_id in self.team_member_ids: + member = self.profile_repo.get_by_id(member_id) + if member: + team_members.append(member) + # Assuming there's a method to assign team members + self.project_repo.assign_team_members(project_id, self.team_member_ids) + + return CommandResult.success_result( + created_project, + f"Project for customer {self.customer_id} created successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create project" + ) + + +class UpdateProjectCommand(Command): + """ + Command to update an existing project. + """ + + def __init__( + self, + project_repo: ProjectRepository, + customer_repo: CustomerRepository, + account_repo: AccountRepository, + profile_repo: ProfileRepository, + project_id: str, + status: Optional[str] = None, + date: Optional[str] = None, + labor: Optional[Union[float, str, Decimal]] = None, + account_id: Optional[str] = None, + team_member_ids: Optional[List[str]] = None, + notes: Optional[str] = None, + amount: Optional[Union[float, str, Decimal]] = None + ): + """ + Initialize the update project command. + + Args: + project_repo: Repository for project operations. + customer_repo: Repository for customer operations. + account_repo: Repository for account operations. + profile_repo: Repository for profile operations. + project_id: ID of the project to update. + status: New status for the project. + date: New date for the project. + labor: New labor cost for the project. + account_id: New account ID for the project. + team_member_ids: New list of team member IDs. + notes: New notes for the project. + amount: New billing amount for the project. + """ + self.project_repo = project_repo + self.customer_repo = customer_repo + self.account_repo = account_repo + self.profile_repo = profile_repo + self.project_id = project_id + self.status = status + self.date = date + self.labor = labor + self.account_id = account_id + self.team_member_ids = team_member_ids + self.notes = notes + self.amount = amount + + def validate(self) -> Dict[str, Any]: + """ + Validate the project update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate project exists + if not is_valid_uuid(self.project_id): + errors.append("Invalid project ID format") + else: + project = self.project_repo.get_by_id(self.project_id) + if not project: + errors.append(f"Project with ID {self.project_id} not found") + + # Validate date format if provided + if not errors and self.date and not is_valid_date(self.date): + errors.append("Invalid date format. Use YYYY-MM-DD.") + + # Validate labor amount if provided + if not errors and self.labor is not None: + labor_validation = validate_decimal_amount(self.labor, 'labor') + if not labor_validation['valid']: + errors.append(labor_validation['error']) + + # Validate billing amount if provided + if not errors and self.amount is not None: + amount_validation = validate_decimal_amount(self.amount, 'amount') + if not amount_validation['valid']: + errors.append(amount_validation['error']) + + # Validate status if provided + if not errors and self.status: + valid_statuses = ['planned', 'in_progress', 'completed', 'cancelled'] + if self.status not in valid_statuses: + errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + # Validate account exists if provided + if not errors and self.account_id: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate team member IDs if provided + if not errors and self.team_member_ids is not None: + for member_id in self.team_member_ids: + if not is_valid_uuid(member_id): + errors.append(f"Invalid team member ID format: {member_id}") + elif not self.profile_repo.get_by_id(member_id): + errors.append(f"Team member with ID {member_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Project]: + """ + Execute the project update command. + + Returns: + CommandResult[Project]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.status is not None: + update_data['status'] = self.status + + if self.date is not None: + update_data['date'] = self.date + + if self.labor is not None: + update_data['labor'] = float(self.labor) + + if self.account_id is not None: + update_data['account_id'] = self.account_id + + if self.notes is not None: + update_data['notes'] = self.notes + + if self.amount is not None: + update_data['amount'] = float(self.amount) + + # Update the project with the data dictionary + updated_project = self.project_repo.update(self.project_id, update_data) + + # Update team members if provided + if self.team_member_ids is not None: + # Assuming there's a method to assign team members + updated_project = self.project_repo.assign_team_members( + self.project_id, + self.team_member_ids + ) + + return CommandResult.success_result( + updated_project, + f"Project {self.project_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update project" + ) + + +class DeleteProjectCommand(Command): + """ + Command to delete a project. + """ + + def __init__(self, project_repo: ProjectRepository, project_id: str): + """ + Initialize the delete project command. + + Args: + project_repo: Repository for project operations. + project_id: ID of the project to delete. + """ + self.project_repo = project_repo + self.project_id = project_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the project deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate project exists + if not is_valid_uuid(self.project_id): + errors.append("Invalid project ID format") + else: + project = self.project_repo.get_by_id(self.project_id) + if not project: + errors.append(f"Project with ID {self.project_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the project deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the project + success = self.project_repo.delete(self.project_id) + + if success: + return CommandResult.success_result( + True, + f"Project {self.project_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete project", + f"Project {self.project_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete project" + ) diff --git a/backend/core/commands/reports/__init__.py b/backend/core/commands/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/reports/reports.py b/backend/core/commands/reports/reports.py new file mode 100644 index 0000000..2bef3b3 --- /dev/null +++ b/backend/core/commands/reports/reports.py @@ -0,0 +1,296 @@ +""" +Commands for handling reports. +""" +from typing import List, Optional +from datetime import date +from django.utils import timezone + +from backend.core.repositories import ReportRepository +from backend.core.models import Service, Project +from backend.core.commands.base import Command, CommandResult + + +class CreateReportCommand(Command): + """ + Command to create a new report. + + Args: + team_member_id: UUID of the team member + report_date: Date of the report + service_ids: Optional list of service IDs to associate + project_ids: Optional list of project IDs to associate + notes: Optional notes about the report + """ + + def __init__( + self, + team_member_id: str, + report_date: date, + service_ids: Optional[List[str]] = None, + project_ids: Optional[List[str]] = None, + notes: Optional[str] = None + ): + self.team_member_id = team_member_id + self.report_date = report_date + self.service_ids = service_ids or [] + self.project_ids = project_ids or [] + self.notes = notes + + def execute(self) -> CommandResult: + """Execute the command to create a report.""" + try: + report_data = { + 'team_member_id': self.team_member_id, + 'date': self.report_date, + 'notes': self.notes + } + + report = ReportRepository.create_with_items( + report_data, + self.service_ids, + self.project_ids + ) + + return CommandResult( + success=True, + data=report, + message="Report created successfully." + ) + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create report" + ) + + +class UpdateReportCommand(Command): + """ + Command to update an existing report. + + Args: + report_id: UUID of the report to update + team_member_id: Optional UUID of the team member + report_date: Optional date of the report + service_ids: Optional list of service IDs to associate + project_ids: Optional list of project IDs to associate + notes: Optional notes about the report + """ + + def __init__( + self, + report_id: str, + team_member_id: Optional[str] = None, + report_date: Optional[date] = None, + service_ids: Optional[List[str]] = None, + project_ids: Optional[List[str]] = None, + notes: Optional[str] = None + ): + self.report_id = report_id + self.team_member_id = team_member_id + self.report_date = report_date + self.service_ids = service_ids + self.project_ids = project_ids + self.notes = notes + + def execute(self) -> CommandResult: + """Execute the command to update a report.""" + try: + # Get the report + report = ReportRepository.get_by_id(self.report_id) + if not report: + return CommandResult( + success=False, + message=f"Report with ID {self.report_id} not found." + ) + + # Update fields if provided + update_data = {} + if self.team_member_id is not None: + update_data['team_member_id'] = self.team_member_id + if self.report_date is not None: + update_data['date'] = self.report_date + if self.notes is not None: + update_data['notes'] = self.notes + + # Update the report + if update_data: + report = ReportRepository.update(self.report_id, update_data) + + # Update related services + if self.service_ids is not None: + services = Service.objects.filter(id__in=self.service_ids) + report.services.set(services) + + # Update related projects + if self.project_ids is not None: + projects = Project.objects.filter(id__in=self.project_ids) + report.projects.set(projects) + + return CommandResult( + success=True, + data=report, + message="Report updated successfully." + ) + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update report" + ) + + +class DeleteReportCommand(Command): + """ + Command to delete a report. + + Args: + report_id: UUID of the report to delete + """ + + def __init__(self, report_id: str): + self.report_id = report_id + + def execute(self) -> CommandResult: + """Execute the command to delete a report.""" + try: + report = ReportRepository.get_by_id(self.report_id) + if not report: + return CommandResult( + success=False, + message=f"Report with ID {self.report_id} not found." + ) + + ReportRepository.delete(self.report_id) + return CommandResult( + success=True, + message="Report deleted successfully." + ) + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete report" + ) + + +class GetTeamMemberReportsCommand(Command): + """ + Command to get reports for a team member within a date range. + + Args: + team_member_id: UUID of the team member + start_date: Optional start date (inclusive) + end_date: Optional end date (inclusive) + """ + + def __init__( + self, + team_member_id: str, + start_date: Optional[date] = None, + end_date: Optional[date] = None + ): + self.team_member_id = team_member_id + self.start_date = start_date + self.end_date = end_date or timezone.now().date() + + def execute(self) -> CommandResult: + """Execute the command to get reports for a team member.""" + try: + # First get reports by team member + reports = ReportRepository.get_by_team_member(self.team_member_id) + + # Then filter by date range if provided + if self.start_date: + reports = reports.filter(date__gte=self.start_date) + if self.end_date: + reports = reports.filter(date__lte=self.end_date) + + # Order by date descending + reports = reports.order_by('-date') + + return CommandResult( + success=True, + data=list(reports), + message=f"Found {reports.count()} reports for the team member." + ) + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to find reports for the team member." + ) + + +class GetTeamMemberActivityCommand(Command): + """ + Command to get activity summary for a team member. + + Args: + team_member_id: UUID of the team member + start_date: Optional start date (inclusive) + end_date: Optional end date (inclusive) + """ + + def __init__( + self, + team_member_id: str, + start_date: Optional[date] = None, + end_date: Optional[date] = None + ): + self.team_member_id = team_member_id + self.start_date = start_date + self.end_date = end_date + + def execute(self) -> CommandResult: + """Execute the command to get activity summary for a team member.""" + try: + activity_summary = ReportRepository.get_team_member_activity( + self.team_member_id, + self.start_date, + self.end_date + ) + + return CommandResult( + success=True, + data=activity_summary, + message="Retrieved team member activity summary." + ) + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve team member activity summary." + ) + + +class GetTeamSummaryCommand(Command): + """ + Command to get activity summary for all team members. + + Args: + start_date: Optional start date (inclusive) + end_date: Optional end date (inclusive) + """ + + def __init__( + self, + start_date: Optional[date] = None, + end_date: Optional[date] = None + ): + self.start_date = start_date + self.end_date = end_date + + def execute(self) -> CommandResult: + """Execute the command to get activity summary for all team members.""" + try: + team_summary = ReportRepository.get_team_summary( + self.start_date, + self.end_date + ) + + return CommandResult( + success=True, + data=team_summary, + message="Retrieved team summary." + ) + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve team summary." + ) \ No newline at end of file diff --git a/backend/core/commands/revenues/__init__.py b/backend/core/commands/revenues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/revenues/revenues.py b/backend/core/commands/revenues/revenues.py new file mode 100644 index 0000000..0358334 --- /dev/null +++ b/backend/core/commands/revenues/revenues.py @@ -0,0 +1,693 @@ +""" +Commands for revenue-related operations. +""" +from typing import Any, Dict, List, Optional + +from backend.core.models.revenues.revenues import Revenue +from backend.core.repositories.revenues.revenues import RevenueRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_date, validate_required_fields, validate_model_exists +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateRevenueCommand(Command): + """ + Command to create a new revenue record. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + account_repo: AccountRepository, + account_id: str, + amount: str, + start_date: str, + end_date: Optional[str] = None + ): + """ + Initialize the create revenue command. + + Args: + revenue_repo: Repository for revenue operations. + account_repo: Repository for account operations. + account_id: ID of the account this revenue belongs to. + amount: Amount of revenue. + start_date: Start date of the revenue (YYYY-MM-DD). + end_date: End date of the revenue (YYYY-MM-DD, optional). + """ + self.revenue_repo = revenue_repo + self.account_repo = account_repo + self.account_id = account_id + self.amount = amount + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the revenue creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + required_fields = ['account_id', 'amount', 'start_date'] + + field_values = { + 'account_id': self.account_id, + 'amount': self.amount, + 'start_date': self.start_date + } + + missing_fields = validate_required_fields(field_values, required_fields) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate account exists + if not errors and self.account_id: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate amount is a valid decimal + if not errors and self.amount: + try: + amount = float(self.amount) + if amount <= 0: + errors.append("Amount must be greater than zero.") + except ValueError: + errors.append("Amount must be a valid number.") + + # Validate date formats + if not errors and self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # Check if there's an active revenue record for this account + if not errors: + active_revenue = self.revenue_repo.get_active_by_account(self.account_id) + if active_revenue: + errors.append( + f"Account already has an active revenue record. End the current record before creating a new one.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Revenue]: + """ + Execute the revenue creation command. + + Returns: + CommandResult[Revenue]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create revenue data + revenue_id = generate_uuid() + + # Create revenue data dictionary + revenue_data = { + 'id': revenue_id, + 'account_id': self.account_id, + 'amount': self.amount, + 'start_date': self.start_date, + 'end_date': self.end_date + } + + # Save to repository + created_revenue = self.revenue_repo.create(revenue_data) + + return CommandResult.success_result( + created_revenue, + f"Revenue record created successfully for account {self.account_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create revenue record" + ) + + +class UpdateRevenueCommand(Command): + """ + Command to update an existing revenue record. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + account_repo: AccountRepository, + revenue_id: str, + amount: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the update revenue command. + + Args: + revenue_repo: Repository for revenue operations. + account_repo: Repository for account operations. + revenue_id: ID of the revenue to update. + amount: New amount for the revenue. + start_date: New start date for the revenue. + end_date: New end date for the revenue. + """ + self.revenue_repo = revenue_repo + self.account_repo = account_repo + self.revenue_id = revenue_id + self.amount = amount + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the revenue update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate revenue exists + if not is_valid_uuid(self.revenue_id): + errors.append("Invalid revenue ID format") + else: + revenue = self.revenue_repo.get_by_id(self.revenue_id) + if not revenue: + errors.append(f"Revenue with ID {self.revenue_id} not found") + + # Validate amount is a valid decimal if provided + if not errors and self.amount is not None: + try: + amount = float(self.amount) + if amount <= 0: + errors.append("Amount must be greater than zero.") + except ValueError: + errors.append("Amount must be a valid number.") + + # Validate date formats if provided + if not errors and self.start_date is not None and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date is not None and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # If only updating end_date, validate it's after the existing start_date + if not errors and self.end_date and not self.start_date: + revenue = self.revenue_repo.get_by_id(self.revenue_id) + if revenue: + end = parse_date(self.end_date) + start = parse_date(revenue.start_date) + if end and start and start > end: + errors.append("End date must be after the existing start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Revenue]: + """ + Execute the revenue update command. + + Returns: + CommandResult[Revenue]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.amount is not None: + update_data['amount'] = self.amount + + if self.start_date is not None: + update_data['start_date'] = self.start_date + + if self.end_date is not None: + update_data['end_date'] = self.end_date + + # Update the revenue with the data dictionary + updated_revenue = self.revenue_repo.update(self.revenue_id, update_data) + + return CommandResult.success_result( + updated_revenue, + f"Revenue {self.revenue_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update revenue" + ) + + +class DeleteRevenueCommand(Command): + """ + Command to delete a revenue record. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + revenue_id: str + ): + """ + Initialize the delete revenue command. + + Args: + revenue_repo: Repository for revenue operations. + revenue_id: ID of the revenue to delete. + """ + self.revenue_repo = revenue_repo + self.revenue_id = revenue_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the revenue deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate revenue exists + if not is_valid_uuid(self.revenue_id): + errors.append("Invalid revenue ID format") + else: + revenue = self.revenue_repo.get_by_id(self.revenue_id) + if not revenue: + errors.append(f"Revenue with ID {self.revenue_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the revenue deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the revenue + success = self.revenue_repo.delete(self.revenue_id) + + if success: + return CommandResult.success_result( + True, + f"Revenue {self.revenue_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete revenue", + f"Revenue {self.revenue_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete revenue" + ) + + +class EndRevenueCommand(Command): + """ + Command to end a revenue record by setting its end date. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + revenue_id: str, + end_date: Optional[str] = None + ): + """ + Initialize the end revenue command. + + Args: + revenue_repo: Repository for revenue operations. + revenue_id: ID of the revenue to end. + end_date: End date for the revenue (defaults to today if not provided). + """ + self.revenue_repo = revenue_repo + self.revenue_id = revenue_id + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the end revenue request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate revenue exists + if not is_valid_uuid(self.revenue_id): + errors.append("Invalid revenue ID format") + else: + revenue = self.revenue_repo.get_by_id(self.revenue_id) + if not revenue: + errors.append(f"Revenue with ID {self.revenue_id} not found") + elif revenue.end_date is not None: + errors.append(f"Revenue is already ended") + + # Validate end date format if provided + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate end date is after start date if provided + if not errors and self.end_date: + revenue = self.revenue_repo.get_by_id(self.revenue_id) + if revenue: + end = parse_date(self.end_date) + start = parse_date(revenue.start_date) + if end and start and start > end: + errors.append("End date must be after the start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Revenue]: + """Execute the end revenue command.""" + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # End the revenue record + if self.end_date: + # Use provided end date + updated_revenue = self.revenue_repo.update(self.revenue_id, {'end_date': self.end_date}) + else: + # Use repository method that sets end date to today + updated_revenue = self.revenue_repo.end_revenue(self.revenue_id) + + if updated_revenue: + return CommandResult.success_result( + updated_revenue, + f"Revenue {self.revenue_id} ended successfully" + ) + else: + return CommandResult.failure_result( + "Failed to end revenue record", + f"Revenue {self.revenue_id} could not be ended" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to end revenue record" + ) + + +class GetRevenueByDateRangeCommand(Command): + """ + Command to get revenue records within a date range. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + account_id: Optional[str] = None + ): + """ + Initialize the get revenue by date range command. + + Args: + revenue_repo: Repository for revenue operations. + start_date: Start date for the range (YYYY-MM-DD). + end_date: End date for the range (YYYY-MM-DD). + account_id: Optional account ID to filter by. + """ + self.revenue_repo = revenue_repo + self.start_date = start_date + self.end_date = end_date + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the get revenue by date range request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate date formats if provided + if self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # Validate account ID if provided + if not errors and self.account_id and not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Revenue]]: + """ + Execute the get revenue by date range command. + + Returns: + CommandResult[List[Revenue]]: Result of the command execution with revenue data. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Parse dates if provided + start_date_obj = parse_date(self.start_date) if self.start_date else None + end_date_obj = parse_date(self.end_date) if self.end_date else None + + # Get revenues by date range + revenues = self.revenue_repo.get_by_date_range(start_date_obj, end_date_obj) + + # Filter by account if provided + if self.account_id: + revenues = revenues.filter(account_id=self.account_id) + + return CommandResult.success_result( + list(revenues), + f"Retrieved revenue data for specified date range" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve revenue data" + ) + + +class CalculateTotalRevenueCommand(Command): + """ + Command to calculate total revenue for an account or all accounts within a date range. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + account_id: Optional[str] = None + ): + self.revenue_repo = revenue_repo + self.start_date = start_date + self.end_date = end_date + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """Validate the calculation request.""" + errors = [] + + # Validate date formats if provided + if self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate date range if both dates provided + if self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[float]: + """Execute the revenue calculation command.""" + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Convert string dates to date objects if provided + start_date_obj = parse_date(self.start_date) if self.start_date else None + end_date_obj = parse_date(self.end_date) if self.end_date else None + + # Calculate total revenue using repository method + total_revenue = self.revenue_repo.get_total_revenue( + account_id=self.account_id, + start_date=start_date_obj, + end_date=end_date_obj + ) + + # Create success message + message = "Total revenue" + if self.account_id: + message += f" for account {self.account_id}" + if self.start_date or self.end_date: + message += " for period" + if self.start_date: + message += f" from {self.start_date}" + if self.end_date: + message += f" to {self.end_date}" + message += f": ${total_revenue:.2f}" + + return CommandResult.success_result( + total_revenue, + message + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to calculate total revenue" + ) + + +class GetActiveRevenuesCommand(Command): + """ + Command to get all active revenue records. + """ + + def __init__( + self, + revenue_repo: RevenueRepository, + account_id: Optional[str] = None + ): + """ + Initialize the get active revenues command. + + Args: + revenue_repo: Repository for revenue operations. + account_id: Optional account ID to filter by. + """ + self.revenue_repo = revenue_repo + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the get active revenues request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account ID if provided + if self.account_id and not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Revenue]]: + """ + Execute the get active revenues command. + + Returns: + CommandResult[List[Revenue]]: Result of the command execution with active revenues. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Get active revenues + if self.account_id: + active_revenue = self.revenue_repo.get_active_by_account(self.account_id) + revenues = [active_revenue] if active_revenue else [] + else: + revenues = list(self.revenue_repo.get_active()) + + return CommandResult.success_result( + revenues, + f"Retrieved active revenue records" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve active revenue records" + ) diff --git a/backend/core/commands/schedules/__init__.py b/backend/core/commands/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/commands/schedules/schedules.py b/backend/core/commands/schedules/schedules.py new file mode 100644 index 0000000..02765ea --- /dev/null +++ b/backend/core/commands/schedules/schedules.py @@ -0,0 +1,848 @@ +""" +Commands for schedule-related operations. +""" +from typing import Any, Dict, List, Optional +from datetime import datetime + +from backend.core.models.schedules.schedules import Schedule +from backend.core.models.services.services import Service +from backend.core.repositories.schedules.schedules import ScheduleRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_date, validate_required_fields, validate_model_exists +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateScheduleCommand(Command): + """ + Command to create a new schedule. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + account_repo: AccountRepository, + account_id: str, + start_date: str, + monday_service: bool = False, + tuesday_service: bool = False, + wednesday_service: bool = False, + thursday_service: bool = False, + friday_service: bool = False, + saturday_service: bool = False, + sunday_service: bool = False, + weekend_service: bool = False, + schedule_exception: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the create schedule command. + + Args: + schedule_repo: Repository for schedule operations. + account_repo: Repository for account operations. + account_id: ID of the account this schedule belongs to. + start_date: Start date of the schedule (YYYY-MM-DD). + monday_service: Whether service is scheduled for Monday. + tuesday_service: Whether service is scheduled for Tuesday. + wednesday_service: Whether service is scheduled for Wednesday. + thursday_service: Whether service is scheduled for Thursday. + friday_service: Whether service is scheduled for Friday. + saturday_service: Whether service is scheduled for Saturday. + sunday_service: Whether service is scheduled for Sunday. + weekend_service: Whether weekend service is enabled. + schedule_exception: Any exceptions to the schedule. + end_date: End date of the schedule (YYYY-MM-DD, optional). + """ + self.schedule_repo = schedule_repo + self.account_repo = account_repo + self.account_id = account_id + self.start_date = start_date + self.monday_service = monday_service + self.tuesday_service = tuesday_service + self.wednesday_service = wednesday_service + self.thursday_service = thursday_service + self.friday_service = friday_service + self.saturday_service = saturday_service + self.sunday_service = sunday_service + self.weekend_service = weekend_service + self.schedule_exception = schedule_exception + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the schedule creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + required_fields = ['account_id', 'start_date'] + + field_values = { + 'account_id': self.account_id, + 'start_date': self.start_date + } + + missing_fields = validate_required_fields(field_values, required_fields) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate account exists + if not errors and self.account_id: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate date formats + if not errors and self.start_date and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # Check if there's an active schedule for this account + if not errors: + active_schedule = self.schedule_repo.get_active_by_account(self.account_id) + if active_schedule: + errors.append( + f"Account already has an active schedule. End the current schedule before creating a new one.") + + # Validate at least one service day is selected + if not errors and not any([ + self.monday_service, self.tuesday_service, self.wednesday_service, + self.thursday_service, self.friday_service, self.saturday_service, + self.sunday_service, self.weekend_service + ]): + errors.append("At least one service day must be selected.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Schedule]: + """ + Execute the schedule creation command. + + Returns: + CommandResult[Schedule]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create schedule data + schedule_id = generate_uuid() + + # Create schedule data dictionary + schedule_data = { + 'id': schedule_id, + 'account_id': self.account_id, + 'monday_service': self.monday_service, + 'tuesday_service': self.tuesday_service, + 'wednesday_service': self.wednesday_service, + 'thursday_service': self.thursday_service, + 'friday_service': self.friday_service, + 'saturday_service': self.saturday_service, + 'sunday_service': self.sunday_service, + 'weekend_service': self.weekend_service, + 'schedule_exception': self.schedule_exception, + 'start_date': self.start_date, + 'end_date': self.end_date + } + + # Save to repository + created_schedule = self.schedule_repo.create(schedule_data) + + return CommandResult.success_result( + created_schedule, + f"Schedule created successfully for account {self.account_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create schedule" + ) + + +class UpdateScheduleCommand(Command): + """ + Command to update an existing schedule. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + account_repo: AccountRepository, + schedule_id: str, + monday_service: Optional[bool] = None, + tuesday_service: Optional[bool] = None, + wednesday_service: Optional[bool] = None, + thursday_service: Optional[bool] = None, + friday_service: Optional[bool] = None, + saturday_service: Optional[bool] = None, + sunday_service: Optional[bool] = None, + weekend_service: Optional[bool] = None, + schedule_exception: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ): + """ + Initialize the update schedule command. + + Args: + schedule_repo: Repository for schedule operations. + account_repo: Repository for account operations. + schedule_id: ID of the schedule to update. + monday_service: Whether service is scheduled for Monday. + tuesday_service: Whether service is scheduled for Tuesday. + wednesday_service: Whether service is scheduled for Wednesday. + thursday_service: Whether service is scheduled for Thursday. + friday_service: Whether service is scheduled for Friday. + saturday_service: Whether service is scheduled for Saturday. + sunday_service: Whether service is scheduled for Sunday. + weekend_service: Whether weekend service is enabled. + schedule_exception: Any exceptions to the schedule. + start_date: Start date of the schedule. + end_date: End date of the schedule. + """ + self.schedule_repo = schedule_repo + self.account_repo = account_repo + self.schedule_id = schedule_id + self.monday_service = monday_service + self.tuesday_service = tuesday_service + self.wednesday_service = wednesday_service + self.thursday_service = thursday_service + self.friday_service = friday_service + self.saturday_service = saturday_service + self.sunday_service = sunday_service + self.weekend_service = weekend_service + self.schedule_exception = schedule_exception + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the schedule update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate schedule exists + if not is_valid_uuid(self.schedule_id): + errors.append("Invalid schedule ID format") + else: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if not schedule: + errors.append(f"Schedule with ID {self.schedule_id} not found") + + # Validate date formats if provided + if not errors and self.start_date is not None and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and self.end_date is not None and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date if both provided + if not errors and self.start_date and self.end_date: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # If only updating end_date, validate it's after the existing start_date + if not errors and self.end_date and not self.start_date: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if schedule: + end = parse_date(self.end_date) + start = parse_date(schedule.start_date) + if end and start and start > end: + errors.append("End date must be after the existing start date.") + + # Check if at least one service day will be selected after update + if not errors: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if schedule: + monday = self.monday_service if self.monday_service is not None else schedule.monday_service + tuesday = self.tuesday_service if self.tuesday_service is not None else schedule.tuesday_service + wednesday = self.wednesday_service if self.wednesday_service is not None else schedule.wednesday_service + thursday = self.thursday_service if self.thursday_service is not None else schedule.thursday_service + friday = self.friday_service if self.friday_service is not None else schedule.friday_service + saturday = self.saturday_service if self.saturday_service is not None else schedule.saturday_service + sunday = self.sunday_service if self.sunday_service is not None else schedule.sunday_service + weekend = self.weekend_service if self.weekend_service is not None else schedule.weekend_service + + if not any([monday, tuesday, wednesday, thursday, friday, saturday, sunday, weekend]): + errors.append("At least one service day must be selected.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Schedule]: + """ + Execute the schedule update command. + + Returns: + CommandResult[Schedule]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.monday_service is not None: + update_data['monday_service'] = self.monday_service + + if self.tuesday_service is not None: + update_data['tuesday_service'] = self.tuesday_service + + if self.wednesday_service is not None: + update_data['wednesday_service'] = self.wednesday_service + + if self.thursday_service is not None: + update_data['thursday_service'] = self.thursday_service + + if self.friday_service is not None: + update_data['friday_service'] = self.friday_service + + if self.saturday_service is not None: + update_data['saturday_service'] = self.saturday_service + + if self.sunday_service is not None: + update_data['sunday_service'] = self.sunday_service + + if self.weekend_service is not None: + update_data['weekend_service'] = self.weekend_service + + if self.schedule_exception is not None: + update_data['schedule_exception'] = self.schedule_exception + + if self.start_date is not None: + update_data['start_date'] = self.start_date + + if self.end_date is not None: + update_data['end_date'] = self.end_date + + # Update the schedule with the data dictionary + updated_schedule = self.schedule_repo.update(self.schedule_id, update_data) + + return CommandResult.success_result( + updated_schedule, + f"Schedule {self.schedule_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update schedule" + ) + + +class DeleteScheduleCommand(Command): + """ + Command to delete a schedule. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + schedule_id: str + ): + """ + Initialize the delete schedule command. + + Args: + schedule_repo: Repository for schedule operations. + schedule_id: ID of the schedule to delete. + """ + self.schedule_repo = schedule_repo + self.schedule_id = schedule_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the schedule deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate schedule exists + if not is_valid_uuid(self.schedule_id): + errors.append("Invalid schedule ID format") + else: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if not schedule: + errors.append(f"Schedule with ID {self.schedule_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the schedule deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the schedule + success = self.schedule_repo.delete(self.schedule_id) + + if success: + return CommandResult.success_result( + True, + f"Schedule {self.schedule_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete schedule", + f"Schedule {self.schedule_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete schedule" + ) + + +class EndScheduleCommand(Command): + """ + Command to end a schedule by setting its end date. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + schedule_id: str, + end_date: Optional[str] = None + ): + """ + Initialize the end schedule command. + + Args: + schedule_repo: Repository for schedule operations. + schedule_id: ID of the schedule to end. + end_date: End date for the schedule (defaults to today if not provided). + """ + self.schedule_repo = schedule_repo + self.schedule_id = schedule_id + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the end schedule request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate schedule exists + if not is_valid_uuid(self.schedule_id): + errors.append("Invalid schedule ID format") + else: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if not schedule: + errors.append(f"Schedule with ID {self.schedule_id} not found") + elif schedule.end_date is not None: + errors.append(f"Schedule is already ended") + + # Validate end date format if provided + if not errors and self.end_date and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate end date is after start date if provided + if not errors and self.end_date: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if schedule: + end = parse_date(self.end_date) + start = parse_date(schedule.start_date) + if end and start and start > end: + errors.append("End date must be after the start date.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Schedule]: + """ + Execute the end schedule command. + + Returns: + CommandResult[Schedule]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # End the schedule + end_date = self.end_date or datetime.now().strftime('%Y-%m-%d') + updated_schedule = self.schedule_repo.update( + self.schedule_id, + {'end_date': end_date} + ) + + if updated_schedule: + return CommandResult.success_result( + updated_schedule, + f"Schedule {self.schedule_id} ended successfully" + ) + else: + return CommandResult.failure_result( + "Failed to end schedule", + f"Schedule {self.schedule_id} could not be ended" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to end schedule" + ) + + +class GetActiveSchedulesCommand(Command): + """ + Command to get all active schedules.py. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + account_id: Optional[str] = None + ): + """ + Initialize the get active schedules.py command. + + Args: + schedule_repo: Repository for schedule operations. + account_id: Optional account ID to filter by. + """ + self.schedule_repo = schedule_repo + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the get active schedules.py request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account ID if provided + if self.account_id and not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Schedule]]: + """ + Execute the get active schedules.py command. + + Returns: + CommandResult[List[Schedule]]: Result of the command execution with active schedules.py. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Get active schedules.py + if self.account_id: + active_schedule = self.schedule_repo.get_active_by_account(self.account_id) + schedules = [active_schedule] if active_schedule else [] + else: + schedules = list(self.schedule_repo.get_active()) + + return CommandResult.success_result( + schedules, + f"Retrieved active schedule records" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve active schedule records" + ) + + +class GenerateServicesCommand(Command): + """ + Command to generate services based on a schedule. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + schedule_id: str, + start_date: str, + end_date: str + ): + """ + Initialize the generate services command. + + Args: + schedule_repo: Repository for schedule operations. + schedule_id: ID of the schedule to generate services from. + start_date: Start date for service generation (YYYY-MM-DD). + end_date: End date for service generation (YYYY-MM-DD). + """ + self.schedule_repo = schedule_repo + self.schedule_id = schedule_id + self.start_date = start_date + self.end_date = end_date + + def validate(self) -> Dict[str, Any]: + """ + Validate the generate services request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate schedule exists + if not is_valid_uuid(self.schedule_id): + errors.append("Invalid schedule ID format") + else: + schedule = self.schedule_repo.get_by_id(self.schedule_id) + if not schedule: + errors.append(f"Schedule with ID {self.schedule_id} not found") + + # Validate date formats + if not errors and not is_valid_date(self.start_date): + errors.append("Invalid start date format. Use YYYY-MM-DD.") + + if not errors and not is_valid_date(self.end_date): + errors.append("Invalid end date format. Use YYYY-MM-DD.") + + # Validate start date is before end date + if not errors: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end and start > end: + errors.append("Start date must be before end date.") + + # Validate date range is not too large (e.g., limit to 3 months) + if not errors: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + if start and end: + date_diff = (end - start).days + if date_diff > 90: # 3 months + errors.append("Date range too large. Maximum range is 90 days.") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Service]]: + """ + Execute the generate services command. + + Returns: + CommandResult[List[Service]]: Result of the command execution with generated services. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Parse dates + start_date_obj = parse_date(self.start_date) + end_date_obj = parse_date(self.end_date) + + # Generate services + services = self.schedule_repo.generate_services( + self.schedule_id, + start_date_obj, + end_date_obj + ) + + return CommandResult.success_result( + services, + f"Generated {len(services)} services from schedule {self.schedule_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to generate services from schedule" + ) + + +class GetScheduleByAccountCommand(Command): + """ + Command to get schedules.py for a specific account. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + account_id: str + ): + """ + Initialize the get schedule by account command. + + Args: + schedule_repo: Repository for schedule operations. + account_id: ID of the account to get schedules.py for. + """ + self.schedule_repo = schedule_repo + self.account_id = account_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the get schedule by account request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate account ID + if not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Schedule]]: + """ + Execute the get schedule by account command. + + Returns: + CommandResult[List[Schedule]]: Result of the command execution with account schedules.py. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Get schedules.py for the account + schedules = list(self.schedule_repo.get_by_account(self.account_id)) + + return CommandResult.success_result( + schedules, + f"Retrieved {len(schedules)} schedules.py for account {self.account_id}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to retrieve schedules.py for account" + ) + + +class SearchSchedulesCommand(Command): + """ + Command to search for schedules.py. + """ + + def __init__( + self, + schedule_repo: ScheduleRepository, + search_term: str + ): + """ + Initialize the search schedules.py command. + + Args: + schedule_repo: Repository for schedule operations. + search_term: Term to search for. + """ + self.schedule_repo = schedule_repo + self.search_term = search_term + + def validate(self) -> Dict[str, Any]: + """ + Validate the search schedules.py request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate search term + if not self.search_term or len(self.search_term.strip()) < 2: + errors.append("Search term must be at least 2 characters long") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Schedule]]: + """ + Execute the search schedules.py command. + + Returns: + CommandResult[List[Schedule]]: Result of the command execution with matching schedules.py. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Search for schedules.py + schedules = list(self.schedule_repo.search(self.search_term)) + + return CommandResult.success_result( + schedules, + f"Found {len(schedules)} schedules.py matching '{self.search_term}'" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to search for schedules.py" + ) diff --git a/backend/core/commands/services/__init__.py b/backend/core/commands/services/__init__.py new file mode 100644 index 0000000..4155894 --- /dev/null +++ b/backend/core/commands/services/__init__.py @@ -0,0 +1,7 @@ +""" +Service commands for managing services. +""" + +from backend.core.commands.services.bulk_schedule import BulkScheduleServicesCommand + +__all__ = ['BulkScheduleServicesCommand'] diff --git a/backend/core/commands/services/bulk_schedule.py b/backend/core/commands/services/bulk_schedule.py new file mode 100644 index 0000000..01a9bb9 --- /dev/null +++ b/backend/core/commands/services/bulk_schedule.py @@ -0,0 +1,118 @@ +""" +Command for bulk scheduling services. +""" +from typing import Any, Dict, List +from datetime import datetime + +from backend.core.commands.base import Command, CommandResult +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.schedules.schedules import ScheduleRepository +from backend.core.factories.services.services import ServiceFactory +from backend.core.utils.validators import validate_required_fields + + +class BulkScheduleServicesCommand(Command): + """ + Command to bulk schedule services for multiple accounts. + """ + + def __init__( + self, + account_repo: AccountRepository, + schedule_repo: ScheduleRepository, + account_ids: List[str], + year: int, + month: int + ): + """ + Initialize the bulk schedule services command. + + Args: + account_repo: Repository for account operations. + schedule_repo: Repository for schedule operations. + account_ids: List of account IDs to schedule services for. + year: The year for scheduling. + month: The month for scheduling (1-12). + """ + self.account_repo = account_repo + self.schedule_repo = schedule_repo + self.account_ids = account_ids + self.year = year + self.month = month + + def validate(self) -> Dict[str, Any]: + """ + Validate the bulk schedule services request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate required fields + field_values = { + 'account_ids': self.account_ids, + 'year': self.year, + 'month': self.month + } + missing_fields = validate_required_fields(field_values, ['account_ids', 'year', 'month']) + if missing_fields: + return { + 'is_valid': False, + 'errors': [f"Required fields missing: {', '.join(missing_fields)}"] + } + + # Validate account IDs + if not self.account_ids: + errors.append("No account IDs provided") + + # Validate year and month + current_year = datetime.now().year + if self.year < current_year or self.year > current_year + 5: + errors.append(f"Year must be between {current_year} and {current_year + 5}") + + if self.month < 1 or self.month > 12: + errors.append("Month must be between 1 and 12") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Dict[str, Any]]: + """ + Execute the bulk schedule services command. + + Returns: + CommandResult[Dict[str, Any]]: Result of the command execution with scheduled services. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Get active schedules for the specified accounts + active_schedules = [] + for account_id in self.account_ids: + active_schedule = self.schedule_repo.get_active_by_account(account_id) + if active_schedule: + active_schedules.append(active_schedule) + + # Generate services for each account + result = ServiceFactory.generate_services_for_accounts( + active_schedules, + self.year, + self.month + ) + + return CommandResult.success_result( + result, + f"Bulk scheduled services for {len(active_schedules)} accounts for {self.month}/{self.year}" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to bulk schedule services" + ) diff --git a/backend/core/commands/services/services.py b/backend/core/commands/services/services.py new file mode 100644 index 0000000..a7dd055 --- /dev/null +++ b/backend/core/commands/services/services.py @@ -0,0 +1,611 @@ +""" +Commands for service-related operations. +""" +from typing import Any, Dict, List, Optional +from datetime import datetime + +from backend.core.models.services.services import Service +from backend.core.repositories.services.services import ServiceRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.profiles.profiles import ProfileRepository +from backend.core.utils.validators import ( + is_valid_uuid, is_valid_date, is_valid_datetime, + validate_required_fields, validate_model_exists +) +from backend.core.utils.helpers import generate_uuid, parse_date +from backend.core.commands.base import Command, CommandResult + + +class CreateServiceCommand(Command): + """ + Command to create a new service. + """ + + def __init__( + self, + service_repo: ServiceRepository, + account_repo: AccountRepository, + profile_repo: ProfileRepository, + account_id: str, + date: str, + status: str = 'scheduled', + team_member_ids: Optional[List[str]] = None, + notes: Optional[str] = None, + deadline_start: Optional[str] = None, + deadline_end: Optional[str] = None + ): + """ + Initialize the create service command. + + Args: + service_repo: Repository for service operations. + account_repo: Repository for account operations. + profile_repo: Repository for profile operations. + account_id: ID of the account the service is for. + date: Date of the service (YYYY-MM-DD). + status: Status of the service ('scheduled', 'in_progress', 'completed', 'cancelled'). + team_member_ids: List of profile IDs for team members assigned to the service. + notes: Additional notes about the service. + deadline_start: Start time of the service deadline (ISO format). + deadline_end: End time of the service deadline (ISO format). + """ + self.service_repo = service_repo + self.account_repo = account_repo + self.profile_repo = profile_repo + self.account_id = account_id + self.date = date + self.status = status + self.team_member_ids = team_member_ids or [] + self.notes = notes + self.deadline_start = deadline_start + self.deadline_end = deadline_end + + def validate(self) -> Dict[str, Any]: + """ + Validate the service creation data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Check required fields + missing_fields = validate_required_fields( + { + 'account_id': self.account_id, + 'date': self.date + }, + ['account_id', 'date'] + ) + + if missing_fields: + errors.append(f"Required fields missing: {', '.join(missing_fields)}") + + # Validate account exists + if not errors and self.account_id: + account_validation = validate_model_exists( + self.account_id, 'account', self.account_repo.get_by_id + ) + if not account_validation['valid']: + errors.append(account_validation['error']) + + # Validate date format + if not errors and self.date and not is_valid_date(self.date): + errors.append("Invalid date format. Use YYYY-MM-DD.") + + # Validate deadline formats + if not errors and self.deadline_start and not is_valid_datetime(self.deadline_start): + errors.append("Invalid deadline_start format. Use ISO format.") + + if not errors and self.deadline_end and not is_valid_datetime(self.deadline_end): + errors.append("Invalid deadline_end format. Use ISO format.") + + # Validate status + valid_statuses = ['scheduled', 'in_progress', 'completed', 'cancelled'] + if not errors and self.status not in valid_statuses: + errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + # Validate team member IDs + if not errors and self.team_member_ids: + for member_id in self.team_member_ids: + if not is_valid_uuid(member_id): + errors.append(f"Invalid team member ID format: {member_id}") + elif not self.profile_repo.get_by_id(member_id): + errors.append(f"Team member with ID {member_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Service]: + """ + Execute the service creation command. + + Returns: + CommandResult[Service]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create service data + service_id = generate_uuid() + + # Get date objects + service_date = parse_date(self.date) + + # Create default deadline times if not provided + deadline_start = self.deadline_start + deadline_end = self.deadline_end + + if not deadline_start and service_date: + # Default to 9:00 AM on the service date + deadline_start = datetime.combine( + service_date, + datetime.min.time().replace(hour=9) + ).isoformat() + + if not deadline_end and service_date: + # Default to 5:00 PM on the service date + deadline_end = datetime.combine( + service_date, + datetime.min.time().replace(hour=17) + ).isoformat() + + # Create service data dictionary instead of Service object + service_data = { + 'id': service_id, + 'account_id': self.account_id, + 'date': self.date, + 'status': self.status, + 'notes': self.notes, + 'deadline_start': deadline_start, + 'deadline_end': deadline_end + } + + # Use create_with_team_members to handle both service creation and team member assignment + created_service = self.service_repo.create_with_team_members( + service_data, + self.team_member_ids or [] + ) + + return CommandResult.success_result( + created_service, + f"Service for account {self.account_id} created successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to create service" + ) + + +class UpdateServiceCommand(Command): + """ + Command to update an existing service. + """ + + def __init__( + self, + service_repo: ServiceRepository, + account_repo: AccountRepository, + profile_repo: ProfileRepository, + service_id: str, + status: Optional[str] = None, + date: Optional[str] = None, + team_member_ids: Optional[List[str]] = None, + notes: Optional[str] = None, + deadline_start: Optional[str] = None, + deadline_end: Optional[str] = None + ): + """ + Initialize the update service command. + + Args: + service_repo: Repository for service operations. + account_repo: Repository for account operations. + profile_repo: Repository for profile operations. + service_id: ID of the service to update. + status: New status for the service. + date: New date for the service. + team_member_ids: New list of team member IDs. + notes: New notes for the service. + deadline_start: New start deadline for the service. + deadline_end: New end deadline for the service. + """ + self.service_repo = service_repo + self.account_repo = account_repo + self.profile_repo = profile_repo + self.service_id = service_id + self.status = status + self.date = date + self.team_member_ids = team_member_ids + self.notes = notes + self.deadline_start = deadline_start + self.deadline_end = deadline_end + + def validate(self) -> Dict[str, Any]: + """ + Validate the service update data. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate service exists + if not is_valid_uuid(self.service_id): + errors.append("Invalid service ID format") + else: + service = self.service_repo.get_by_id(self.service_id) + if not service: + errors.append(f"Service with ID {self.service_id} not found") + + # Validate date format if provided + if not errors and self.date and not is_valid_date(self.date): + errors.append("Invalid date format. Use YYYY-MM-DD.") + + # Validate deadline formats if provided + if not errors and self.deadline_start and not is_valid_datetime(self.deadline_start): + errors.append("Invalid deadline_start format. Use ISO format.") + + if not errors and self.deadline_end and not is_valid_datetime(self.deadline_end): + errors.append("Invalid deadline_end format. Use ISO format.") + + # Validate status if provided + if not errors and self.status: + valid_statuses = ['scheduled', 'in_progress', 'completed', 'cancelled'] + if self.status not in valid_statuses: + errors.append(f"Invalid status. Must be one of: {', '.join(valid_statuses)}") + + # Validate team member IDs if provided + if not errors and self.team_member_ids is not None: + for member_id in self.team_member_ids: + if not is_valid_uuid(member_id): + errors.append(f"Invalid team member ID format: {member_id}") + elif not self.profile_repo.get_by_id(member_id): + errors.append(f"Team member with ID {member_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Service]: + """ + Execute the service update command. + + Returns: + CommandResult[Service]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Create a dictionary of fields to update + update_data = {} + + # Add fields to update_data if they were provided + if self.status is not None: + update_data['status'] = self.status + + if self.date is not None: + update_data['date'] = self.date + + if self.notes is not None: + update_data['notes'] = self.notes + + if self.deadline_start is not None: + update_data['deadline_start'] = self.deadline_start + + if self.deadline_end is not None: + update_data['deadline_end'] = self.deadline_end + + # Update the service with the data dictionary + updated_service = self.service_repo.update(self.service_id, update_data) + + # Update team members if provided + if self.team_member_ids is not None: + updated_service = self.service_repo.assign_team_members( + self.service_id, + self.team_member_ids + ) + + return CommandResult.success_result( + updated_service, + f"Service {self.service_id} updated successfully" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to update service" + ) + + +class DeleteServiceCommand(Command): + """ + Command to delete a service. + """ + + def __init__(self, service_repo: ServiceRepository, service_id: str): + """ + Initialize the delete service command. + + Args: + service_repo: Repository for service operations. + service_id: ID of the service to delete. + """ + self.service_repo = service_repo + self.service_id = service_id + + def validate(self) -> Dict[str, Any]: + """ + Validate the service deletion request. + + Returns: + Dict[str, Any]: Validation result with 'is_valid' and optional 'errors'. + """ + errors = [] + + # Validate service exists + if not is_valid_uuid(self.service_id): + errors.append("Invalid service ID format") + else: + service = self.service_repo.get_by_id(self.service_id) + if not service: + errors.append(f"Service with ID {self.service_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[bool]: + """ + Execute the service deletion command. + + Returns: + CommandResult[bool]: Result of the command execution. + """ + # Validate command data + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + # Delete the service + success = self.service_repo.delete(self.service_id) + + if success: + return CommandResult.success_result( + True, + f"Service {self.service_id} deleted successfully" + ) + else: + return CommandResult.failure_result( + "Failed to delete service", + f"Service {self.service_id} could not be deleted" + ) + + except Exception as e: + return CommandResult.failure_result( + str(e), + "Failed to delete service" + ) + +class CompleteServiceCommand(Command): + """Command to mark a service as complete.""" + + def __init__( + self, + service_repo: ServiceRepository, + service_id: str, + completion_notes: Optional[str] = None + ): + self.service_repo = service_repo + self.service_id = service_id + self.completion_notes = completion_notes + + def validate(self) -> Dict[str, Any]: + errors = [] + if not is_valid_uuid(self.service_id): + errors.append("Invalid service ID format") + else: + service = self.service_repo.get_by_id(self.service_id) + if not service: + errors.append(f"Service with ID {self.service_id} not found") + elif service.status == 'completed': + errors.append("Service is already completed") + elif service.status == 'cancelled': + errors.append("Cancelled service cannot be completed") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Service]: + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + update_data = { + 'status': 'completed', + 'notes': self.completion_notes + } + updated_service = self.service_repo.update(self.service_id, update_data) + return CommandResult.success_result( + updated_service, + f"Service {self.service_id} marked as completed" + ) + except Exception as e: + return CommandResult.failure_result(str(e)) + + +class CancelServiceCommand(Command): + """Command to cancel a service.""" + + def __init__( + self, + service_repo: ServiceRepository, + service_id: str, + cancellation_reason: Optional[str] = None + ): + self.service_repo = service_repo + self.service_id = service_id + self.cancellation_reason = cancellation_reason + + def validate(self) -> Dict[str, Any]: + errors = [] + if not is_valid_uuid(self.service_id): + errors.append("Invalid service ID format") + else: + service = self.service_repo.get_by_id(self.service_id) + if not service: + errors.append(f"Service with ID {self.service_id} not found") + elif service.status == 'cancelled': + errors.append("Service is already cancelled") + elif service.status == 'completed': + errors.append("Completed service cannot be cancelled") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Service]: + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + update_data = { + 'status': 'cancelled', + 'notes': self.cancellation_reason + } + updated_service = self.service_repo.update(self.service_id, update_data) + return CommandResult.success_result( + updated_service, + f"Service {self.service_id} cancelled successfully" + ) + except Exception as e: + return CommandResult.failure_result(str(e)) + + +class AssignTeamMembersCommand(Command): + """Command to assign team members to a service.""" + + def __init__( + self, + service_repo: ServiceRepository, + profile_repo: ProfileRepository, + service_id: str, + team_member_ids: List[str] + ): + self.service_repo = service_repo + self.profile_repo = profile_repo + self.service_id = service_id + self.team_member_ids = team_member_ids + + def validate(self) -> Dict[str, Any]: + errors = [] + if not is_valid_uuid(self.service_id): + errors.append("Invalid service ID format") + else: + service = self.service_repo.get_by_id(self.service_id) + if not service: + errors.append(f"Service with ID {self.service_id} not found") + + for member_id in self.team_member_ids: + if not is_valid_uuid(member_id): + errors.append(f"Invalid team member ID format: {member_id}") + elif not self.profile_repo.get_by_id(member_id): + errors.append(f"Team member with ID {member_id} not found") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[Service]: + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + updated_service = self.service_repo.assign_team_members( + self.service_id, + self.team_member_ids + ) + return CommandResult.success_result( + updated_service, + f"Team members assigned to service {self.service_id} successfully" + ) + except Exception as e: + return CommandResult.failure_result(str(e)) + + +class GetServicesByDateRangeCommand(Command): + """Command to get services within a date range.""" + + def __init__( + self, + service_repo: ServiceRepository, + start_date: str, + end_date: str, + account_id: Optional[str] = None, + team_member_id: Optional[str] = None + ): + self.service_repo = service_repo + self.start_date = start_date + self.end_date = end_date + self.account_id = account_id + self.team_member_id = team_member_id + + def validate(self) -> Dict[str, Any]: + errors = [] + if not is_valid_date(self.start_date): + errors.append("Invalid start date format") + if not is_valid_date(self.end_date): + errors.append("Invalid end date format") + + if self.account_id and not is_valid_uuid(self.account_id): + errors.append("Invalid account ID format") + if self.team_member_id and not is_valid_uuid(self.team_member_id): + errors.append("Invalid team member ID format") + + return { + 'is_valid': len(errors) == 0, + 'errors': errors + } + + def execute(self) -> CommandResult[List[Service]]: + validation = self.validate() + if not validation['is_valid']: + return CommandResult.failure_result(validation['errors']) + + try: + start = parse_date(self.start_date) + end = parse_date(self.end_date) + + services = self.service_repo.filter_services( + date_from=start, + date_to=end, + account_id=self.account_id, + team_member_id=self.team_member_id + ) + return CommandResult.success_result( + list(services), + f"Found {services.count()} services in date range" + ) + except Exception as e: + return CommandResult.failure_result(str(e)) + diff --git a/backend/core/factories/__init__.py b/backend/core/factories/__init__.py new file mode 100644 index 0000000..9106120 --- /dev/null +++ b/backend/core/factories/__init__.py @@ -0,0 +1,3 @@ +""" +Factory module for creating domain objects. +""" \ No newline at end of file diff --git a/backend/core/factories/services/__init__.py b/backend/core/factories/services/__init__.py new file mode 100644 index 0000000..67e41bd --- /dev/null +++ b/backend/core/factories/services/__init__.py @@ -0,0 +1,7 @@ +""" +Service factory module for creating service objects. +""" + +from backend.core.factories.services.services import ServiceFactory + +__all__ = ['ServiceFactory'] \ No newline at end of file diff --git a/backend/core/factories/services/services.py b/backend/core/factories/services/services.py new file mode 100644 index 0000000..5a11998 --- /dev/null +++ b/backend/core/factories/services/services.py @@ -0,0 +1,202 @@ +""" +Factory for creating Service objects. +""" +from datetime import datetime, date, timedelta +from typing import List, Dict, Any, Optional + +from backend.core.models import Schedule, Service, Profile +from backend.core.utils.helpers import get_month_start_end, dict_to_json + + +class ServiceFactory: + """ + Factory for creating Service objects based on schedules. + """ + + @staticmethod + def serialize_service(service: Service) -> Dict[str, Any]: + """ + Convert a Service object to a serializable dictionary. + + Args: + service: The Service object to serialize + + Returns: + Dict[str, Any]: A dictionary representation of the Service object + """ + return { + 'id': str(service.id), + 'account_id': str(service.account.id), + 'account_name': service.account.name, + 'date': service.date.isoformat(), + 'status': service.status, + 'notes': service.notes, + 'deadline_start': service.deadline_start.isoformat(), + 'deadline_end': service.deadline_end.isoformat(), + 'created_at': service.created_at.isoformat(), + 'updated_at': service.updated_at.isoformat(), + 'completed_at': service.completed_at.isoformat() if service.completed_at else None, + 'team_member_ids': [str(member.id) for member in service.team_members.all()], + 'team_member_names': service.team_member_names + } + + @classmethod + def create_service(cls, schedule: Schedule, service_date: date, is_weekend: bool = False) -> Service: + """ + Create a Service object for a specific date based on a schedule. + + Args: + schedule: The schedule to base the service on + service_date: The date for the service + is_weekend: Whether this is a weekend service + + Returns: + Service: The created service object + """ + # Set default deadline times (6:00pm the day of service to 6:00am the following day) + deadline_start = datetime.combine(service_date, datetime.min.time()).replace(hour=18) # 6:00pm + deadline_end = datetime.combine(service_date + timedelta(days=1), datetime.min.time()).replace(hour=6) # 6:00am next day + + notes = None + if is_weekend: + notes = "Weekend Service" + + # Create the service + service = Service.objects.create( + account=schedule.account, + date=service_date, + status='scheduled', + notes=notes, + deadline_start=deadline_start, + deadline_end=deadline_end + ) + + # Add admin profile to team_members by default + admin_profile_id = "7dc00b89-72d1-4dea-a1ed-4cbef220aa0c" + try: + admin_profile = Profile.objects.get(id=admin_profile_id) + service.team_members.add(admin_profile) + except Profile.DoesNotExist: + # If admin profile doesn't exist, continue without adding it + pass + + return service + + @classmethod + def generate_services_for_month(cls, schedule: Schedule, year: int, month: int) -> Dict[str, Any]: + """ + Generate services for a specific month based on a schedule. + + Args: + schedule: The schedule to base the services on + year: The year + month: The month (1-12) + + Returns: + Dict[str, Any]: Dictionary with generated services and any errors + """ + if schedule.schedule_exception: + return { + 'services': [], + 'success': False, + 'errors': [f"Schedule exception: {schedule.schedule_exception}"], + 'message': "Schedule has an exception and requires manual scheduling" + } + + # Get the start and end dates of the month + month_start = date(year, month, 1) + month_end = get_month_start_end(datetime(year, month, 1))[1].date() + + services = [] + errors = [] + + # Iterate through each day in the month + current_date = month_start + while current_date <= month_end: + weekday = current_date.weekday() # Monday is 0, Sunday is 6 + day_name = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'][weekday] + + # Check if service is required for this day + is_service_day = schedule.has_service_on_day(day_name) + is_weekend = weekday >= 5 and schedule.weekend_service + + # If it's a service day or a weekend with weekend_service enabled + if is_service_day or is_weekend: + # TODO: Check if it's a holiday (would need a holiday model/service) + # For now, we'll assume it's not a holiday + is_holiday = False + + if not is_holiday: + try: + # For weekend services, use the actual date but mark as weekend service + service = cls.create_service(schedule, current_date, is_weekend=is_weekend) + # Serialize the service object to make it JSON serializable + serialized_service = cls.serialize_service(service) + services.append(serialized_service) + except Exception as e: + errors.append(f"Error creating service for {current_date}: {str(e)}") + + current_date += timedelta(days=1) + + return { + 'services': services, + 'success': len(services) > 0, + 'errors': errors, + 'message': f"Generated {len(services)} services for {month}/{year}" + } + + @classmethod + def generate_services_for_accounts(cls, schedules: List[Schedule], year: int, month: int) -> Dict[str, Any]: + """ + Generate services for multiple accounts based on their schedules. + + Args: + schedules: List of schedules to generate services for + year: The year + month: The month (1-12) + + Returns: + Dict[str, Any]: Dictionary with results for each account + """ + results = {} + overall_success = True + overall_errors = [] + + for schedule in schedules: + account_id = str(schedule.account.id) + account_name = schedule.account.name + + # Skip inactive schedules + if not schedule.is_active: + results[account_id] = { + 'account_name': account_name, + 'services': [], + 'success': False, + 'errors': ["Schedule is inactive"], + 'message': "Schedule is inactive" + } + continue + + # Generate services for this account + account_result = cls.generate_services_for_month(schedule, year, month) + + # Add account name to the result + account_result['account_name'] = account_name + + # Store the result + results[account_id] = account_result + + # Update overall success and errors + if not account_result['success']: + overall_success = False + + if account_result['errors']: + for error in account_result['errors']: + overall_errors.append(f"{account_name}: {error}") + + return { + 'account_results': results, + 'success': overall_success, + 'errors': overall_errors, + 'message': f"Generated services for {len(results)} accounts for {month}/{year}" + } diff --git a/backend/core/models/__init__.py b/backend/core/models/__init__.py new file mode 100644 index 0000000..22634dc --- /dev/null +++ b/backend/core/models/__init__.py @@ -0,0 +1,32 @@ +""" +Models package initialization. +Import all models here to make them available when importing from core.models +""" +from django.contrib.auth.models import User + +from backend.core.models.profiles.profiles import Profile +from backend.core.models.customers.customers import Customer +from backend.core.models.accounts.accounts import Account +from backend.core.models.revenues.revenues import Revenue +from backend.core.models.labor.labor import Labor +from backend.core.models.schedules.schedules import Schedule +from backend.core.models.services.services import Service +from backend.core.models.projects.projects import Project +from backend.core.models.invoices.invoices import Invoice +from backend.core.models.reports.reports import Report +from backend.core.models.punchlists.punchlists import Punchlist + +__all__ = [ + 'User', + 'Profile', + 'Customer', + 'Account', + 'Revenue', + 'Labor', + 'Schedule', + 'Service', + 'Project', + 'Invoice', + 'Report', + 'Punchlist', +] diff --git a/backend/core/models/accounts/__init__.py b/backend/core/models/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/accounts/accounts.py b/backend/core/models/accounts/accounts.py new file mode 100644 index 0000000..31285b4 --- /dev/null +++ b/backend/core/models/accounts/accounts.py @@ -0,0 +1,89 @@ +""" +Account models for managing customer accounts. +""" +import uuid +from django.db import models +from django.utils import timezone + +from backend.core.models.revenues.revenues import Revenue +from backend.core.models.labor.labor import Labor + + +class Account(models.Model): + """Account model belonging to a customer""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='accounts') + name = models.CharField(max_length=200) + + # Address + street_address = models.CharField(max_length=255) + city = models.CharField(max_length=100) + state = models.CharField(max_length=100) + zip_code = models.CharField(max_length=20) + + # Contact + primary_contact_first_name = models.CharField(max_length=100) + primary_contact_last_name = models.CharField(max_length=100) + primary_contact_phone = models.CharField(max_length=20) + primary_contact_email = models.EmailField() + + # Secondary contact (optional) + secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True) + secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True) + secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True) + secondary_contact_email = models.EmailField(blank=True, null=True) + + # Dates + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Account' + verbose_name_plural = 'Accounts' + ordering = ['name'] + indexes = [ + models.Index(fields=['customer']), + models.Index(fields=['name']), + ] + + def __str__(self): + return f"{self.name} ({self.customer.name})" + + @property + def is_active(self): + """Check if account is active based on end_date""" + return self.end_date is None or self.end_date > timezone.now().date() + + @property + def primary_contact_full_name(self): + """Get contact's full name""" + return f"{self.primary_contact_first_name} {self.primary_contact_last_name}" + + @property + def secondary_contact_full_name(self): + """Get contact's full name""" + return f"{self.secondary_contact_first_name} {self.secondary_contact_last_name}" + + @property + def address(self): + """Get formatted address""" + return f"{self.street_address}, {self.city}, {self.state} {self.zip_code}" + + def current_revenue(self): + """Get the current active revenue for this account""" + return Revenue.objects.filter( + account=self, + start_date__lte=timezone.now().date(), + end_date__isnull=True + ).first() + + def current_labor(self): + """Get the current active labor for this account""" + return Labor.objects.filter( + account=self, + start_date__lte=timezone.now().date(), + end_date__isnull=True + ).first() \ No newline at end of file diff --git a/backend/core/models/customers/__init__.py b/backend/core/models/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/customers/customers.py b/backend/core/models/customers/customers.py new file mode 100644 index 0000000..0691ffd --- /dev/null +++ b/backend/core/models/customers/customers.py @@ -0,0 +1,80 @@ +""" +Customer models for managing customer information. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Customer(models.Model): + """Customer model with contact information""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=200) + + # Primary contact + primary_contact_first_name = models.CharField(max_length=100) + primary_contact_last_name = models.CharField(max_length=100) + primary_contact_phone = models.CharField(max_length=20) + primary_contact_email = models.EmailField() + + # Secondary contact (optional) + secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True) + secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True) + secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True) + secondary_contact_email = models.EmailField(blank=True, null=True) + + # Billing information + billing_contact_first_name = models.CharField(max_length=100) + billing_contact_last_name = models.CharField(max_length=100) + billing_street_address = models.CharField(max_length=255) + billing_city = models.CharField(max_length=100) + billing_state = models.CharField(max_length=100) + billing_zip_code = models.CharField(max_length=20) + billing_email = models.EmailField() + billing_terms = models.TextField() + + # Dates + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Customer' + verbose_name_plural = 'Customers' + ordering = ['name'] + indexes = [ + models.Index(fields=['name']), + models.Index(fields=['primary_contact_email']), + ] + + def __str__(self): + return self.name + + @property + def is_active(self): + """Check if customer is active based on end_date""" + return self.end_date is None or self.end_date > timezone.now().date() + + @property + def primary_contact_full_name(self): + """Get primary contact's full name""" + return f"{self.primary_contact_first_name} {self.primary_contact_last_name}" + + @property + def secondary_contact_full_name(self): + """Get secondary contact's full name""" + if self.secondary_contact_first_name and self.secondary_contact_last_name: + return f"{self.secondary_contact_first_name} {self.secondary_contact_last_name}" + return None + + @property + def billing_contact_full_name(self): + """Get billing contact's full name""" + return f"{self.billing_contact_first_name} {self.billing_contact_last_name}" + + @property + def billing_address(self): + """Get formatted billing address""" + return f"{self.billing_street_address}, {self.billing_city}, {self.billing_state} {self.billing_zip_code}" \ No newline at end of file diff --git a/backend/core/models/invoices/__init__.py b/backend/core/models/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/invoices/invoices.py b/backend/core/models/invoices/invoices.py new file mode 100644 index 0000000..64ecf85 --- /dev/null +++ b/backend/core/models/invoices/invoices.py @@ -0,0 +1,83 @@ +""" +Invoice models for tracking customer billing. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Invoice(models.Model): + """Invoice records""" + STATUS_CHOICES = ( + ('draft', 'Draft'), + ('sent', 'Sent'), + ('paid', 'Paid'), + ('overdue', 'Overdue'), + ('cancelled', 'Cancelled'), + ) + PAYMENT_CHOICES = ( + ('check', 'Check'), + ('credit_card', 'Credit Card'), + ('bank_transfer', 'Bank Transfer'), + ('cash', 'Cash'), + ) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='invoices') + date = models.DateField() + + # Related items + accounts = models.ManyToManyField('Account', related_name='invoices', blank=True) + projects = models.ManyToManyField('Project', related_name='invoices', blank=True) + + # Status and payment + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + date_paid = models.DateField(blank=True, null=True) + payment_type = models.CharField(max_length=20, choices=PAYMENT_CHOICES, blank=True, null=True) + + # Financial + total_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + sent_at = models.DateTimeField(blank=True, null=True) + + class Meta: + app_label = 'core' + verbose_name = 'Invoice' + verbose_name_plural = 'Invoices' + ordering = ['-date'] + indexes = [ + models.Index(fields=['customer']), + models.Index(fields=['date']), + models.Index(fields=['status']), + ] + + def __str__(self): + return f"Invoice for {self.customer.name} on {self.date}" + + def save(self, *args, **kwargs): + """Override save to update timestamps based on status""" + if self.status == 'sent' and not self.sent_at: + self.sent_at = timezone.now() + super().save(*args, **kwargs) + + @property + def is_paid(self): + """Check if invoice is paid""" + return self.status == 'paid' + + @property + def is_overdue(self): + """Check if invoice is overdue""" + # Normally you'd check against a due date field, but we'll use a simple 30 day rule + if self.status not in ['paid', 'cancelled'] and self.sent_at: + return (timezone.now().date() - self.date).days > 30 + return False + + @property + def days_outstanding(self): + """Calculate days since invoice was sent""" + if not self.sent_at: + return 0 + return (timezone.now().date() - self.sent_at.date()).days \ No newline at end of file diff --git a/backend/core/models/labor/__init__.py b/backend/core/models/labor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/labor/labor.py b/backend/core/models/labor/labor.py new file mode 100644 index 0000000..a2119b9 --- /dev/null +++ b/backend/core/models/labor/labor.py @@ -0,0 +1,42 @@ +""" +Labor models for tracking account labor costs. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Labor(models.Model): + """Labor records for accounts""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='labors') + amount = models.DecimalField(max_digits=10, decimal_places=2) + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Labor' + verbose_name_plural = 'Labor' + ordering = ['-start_date'] + indexes = [ + models.Index(fields=['account']), + models.Index(fields=['start_date']), + ] + + def __str__(self): + return f"{self.account.name} - ${self.amount}" + + @property + def is_active(self): + """Check if labor record is active based on end_date""" + return self.end_date is None or self.end_date > timezone.now().date() + + @property + def duration_days(self): + """Calculate the duration in days""" + if not self.end_date: + return (timezone.now().date() - self.start_date).days + return (self.end_date - self.start_date).days \ No newline at end of file diff --git a/backend/core/models/profiles/__init__.py b/backend/core/models/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/profiles/profiles.py b/backend/core/models/profiles/profiles.py new file mode 100644 index 0000000..d89d87d --- /dev/null +++ b/backend/core/models/profiles/profiles.py @@ -0,0 +1,73 @@ +""" +Profile models for user extensions. +""" +import uuid + +from django.core.validators import RegexValidator +from django.db import models +from django.contrib.auth.models import User +from django.db.models.signals import post_save +from django.dispatch import receiver + + +class Profile(models.Model): + """Extension of the Django User model""" + ROLE_CHOICES = ( + ('admin', 'Admin'), + ('team_leader', 'Team Leader'), + ('team_member', 'Team Member'), + ) + + phone_regex = RegexValidator( + regex=r'^\+?1?\d{9,15}$', + message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed." + ) + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + primary_phone = models.CharField(max_length=20) + secondary_phone = models.CharField(max_length=20, blank=True, null=True) + email = models.EmailField() + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='team_member') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Profile' + verbose_name_plural = 'Profiles' + ordering = ['first_name', 'last_name'] + + def __str__(self): + return f"{self.first_name} {self.last_name}" + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + @property + def is_admin(self): + return self.role == 'admin' + + @property + def is_team_leader(self): + return self.role == 'team_leader' + + @property + def is_team_member(self): + return self.role == 'team_member' + + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + """Create a Profile when a new User is created""" + if created and not hasattr(instance, 'profile'): + Profile.objects.create( + user=instance, + first_name=instance.first_name or '', + last_name=instance.last_name or '', + email=instance.email or '', + primary_phone='' # This will need to be updated later + ) \ No newline at end of file diff --git a/backend/core/models/projects/__init__.py b/backend/core/models/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/projects/projects.py b/backend/core/models/projects/projects.py new file mode 100644 index 0000000..eb32a36 --- /dev/null +++ b/backend/core/models/projects/projects.py @@ -0,0 +1,80 @@ +""" +Project models for tracking customer projects. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Project(models.Model): + """Project records for customers""" + STATUS_CHOICES = ( + ('planned', 'Planned'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + customer = models.ForeignKey('Customer', on_delete=models.CASCADE, related_name='projects') + account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='projects', blank=True, null=True) + date = models.DateField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='planned') + team_members = models.ManyToManyField('Profile', related_name='projects') + notes = models.TextField(blank=True, null=True) + + # Financial + labor = models.DecimalField(max_digits=10, decimal_places=2) + amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(blank=True, null=True) + + class Meta: + app_label = 'core' + verbose_name = 'Project' + verbose_name_plural = 'Projects' + ordering = ['-date'] + indexes = [ + models.Index(fields=['customer']), + models.Index(fields=['account']), + models.Index(fields=['date']), + models.Index(fields=['status']), + ] + + def __str__(self): + return f"Project for {self.customer.name} on {self.date}" + + def save(self, *args, **kwargs): + """Override save to set completed_at when status changes to completed""" + if self.status == 'completed' and not self.completed_at: + self.completed_at = timezone.now() + super().save(*args, **kwargs) + + @property + def is_upcoming(self): + """Check if project is upcoming""" + return self.date > timezone.now().date() + + @property + def is_past_due(self): + """Check if project is past due""" + return self.date < timezone.now().date() and self.status not in ['completed', 'cancelled'] + + @property + def team_member_names(self): + """Get list of team member names""" + return [f"{member.first_name} {member.last_name}" for member in self.team_members.all()] + + @property + def profit(self): + """Calculate profit (amount - labor)""" + return self.amount - self.labor + + @property + def profit_margin(self): + """Calculate profit margin percentage""" + if self.amount == 0: + return 0 + return (self.profit / self.amount) * 100 \ No newline at end of file diff --git a/backend/core/models/punchlists/__init__.py b/backend/core/models/punchlists/__init__.py new file mode 100644 index 0000000..a3d5ffc --- /dev/null +++ b/backend/core/models/punchlists/__init__.py @@ -0,0 +1,3 @@ +""" +Punchlist models for tracking project punchlists. +""" \ No newline at end of file diff --git a/backend/core/models/punchlists/punchlists.py b/backend/core/models/punchlists/punchlists.py new file mode 100644 index 0000000..c1c15ca --- /dev/null +++ b/backend/core/models/punchlists/punchlists.py @@ -0,0 +1,95 @@ +""" +Punchlist models for tracking project punchlists. + +This is a customizable punchlist model. Modify the fields to match your specific +service requirements. The current structure provides generic sections that can +be adapted for various industries. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Punchlist(models.Model): + """ + Punchlist records for projects. + + Customize the fields below to match your service workflow. + The current structure provides generic sections for: + - Front area (customer-facing) + - Main work area + - Equipment + - Back area + - End of visit checklist + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + project = models.ForeignKey('Project', on_delete=models.CASCADE, related_name='punchlists') + account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='punchlists') + date = models.DateField() + second_visit = models.BooleanField(default=False) + second_date = models.DateTimeField(blank=True, null=True) + + # Front area section + front_ceiling = models.BooleanField(default=False) + front_vents = models.BooleanField(default=False) + front_fixtures = models.BooleanField(default=False) + front_counter = models.BooleanField(default=False) + + # Main work area section + main_equipment = models.CharField(max_length=20, blank=True) + main_equipment_disassemble = models.BooleanField(default=False) + main_equipment_reassemble = models.BooleanField(default=False) + main_equipment_alerts = models.BooleanField(default=False) + main_equipment_exterior = models.BooleanField(default=False) + main_walls = models.BooleanField(default=False) + main_fixtures = models.BooleanField(default=False) + main_ceiling = models.BooleanField(default=False) + main_vents = models.BooleanField(default=False) + main_floors = models.BooleanField(default=False) + + # Equipment section + equip_primary = models.BooleanField(default=False) + equip_station_1 = models.BooleanField(default=False) + equip_station_2 = models.BooleanField(default=False) + equip_station_3 = models.BooleanField(default=False) + equip_storage = models.BooleanField(default=False) + equip_prep = models.BooleanField(default=False) + equip_delivery = models.BooleanField(default=False) + equip_office = models.BooleanField(default=False) + equip_sinks = models.BooleanField(default=False) + equip_dispensers = models.BooleanField(default=False) + equip_other = models.BooleanField(default=False) + + # Back area section + back_ceiling = models.BooleanField(default=False) + back_vents = models.BooleanField(default=False) + + # End of visit section + end_trash = models.BooleanField(default=False) + end_clean = models.BooleanField(default=False) + end_secure = models.BooleanField(default=False) + + # Notes + notes = models.TextField(blank=True) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + exported_at = models.DateTimeField(blank=True, null=True) + + # Google API results (optional integration) + sheet_url = models.URLField(blank=True, null=True) + pdf_url = models.URLField(blank=True, null=True) + + class Meta: + app_label = 'core' + ordering = ['-date'] + verbose_name_plural = "Punchlists" + indexes = [ + models.Index(fields=['project']), + models.Index(fields=['account']), + models.Index(fields=['date']), + ] + + def __str__(self): + return f"Punchlist for {self.account.name} on {self.date}" diff --git a/backend/core/models/reports/__init__.py b/backend/core/models/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/reports/reports.py b/backend/core/models/reports/reports.py new file mode 100644 index 0000000..8fae40a --- /dev/null +++ b/backend/core/models/reports/reports.py @@ -0,0 +1,42 @@ +""" +Report models for tracking team member reports. +""" +import uuid +from django.db import models + + +class Report(models.Model): + """Report records""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + date = models.DateField() + team_member = models.ForeignKey('Profile', on_delete=models.CASCADE, related_name='reports') + services = models.ManyToManyField('Service', related_name='reports', blank=True) + projects = models.ManyToManyField('Project', related_name='reports', blank=True) + notes = models.TextField(blank=True, null=True) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Report' + verbose_name_plural = 'Reports' + ordering = ['-date'] + indexes = [ + models.Index(fields=['team_member']), + models.Index(fields=['date']), + ] + + def __str__(self): + return f"Report by {self.team_member.first_name} {self.team_member.last_name} on {self.date}" + + @property + def service_count(self): + """Count of services in this report""" + return self.services.count() + + @property + def project_count(self): + """Count of projects in this report""" + return self.projects.count() \ No newline at end of file diff --git a/backend/core/models/revenues/__init__.py b/backend/core/models/revenues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/revenues/revenues.py b/backend/core/models/revenues/revenues.py new file mode 100644 index 0000000..033d576 --- /dev/null +++ b/backend/core/models/revenues/revenues.py @@ -0,0 +1,42 @@ +""" +Revenue models for tracking account revenue. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Revenue(models.Model): + """Revenue records for accounts""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='revenues') + amount = models.DecimalField(max_digits=10, decimal_places=2) + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Revenue' + verbose_name_plural = 'Revenues' + ordering = ['-start_date'] + indexes = [ + models.Index(fields=['account']), + models.Index(fields=['start_date']), + ] + + def __str__(self): + return f"{self.account.name} - ${self.amount}" + + @property + def is_active(self): + """Check if revenue record is active based on end_date""" + return self.end_date is None or self.end_date > timezone.now().date() + + @property + def duration_days(self): + """Calculate the duration in days""" + if not self.end_date: + return (timezone.now().date() - self.start_date).days + return (self.end_date - self.start_date).days \ No newline at end of file diff --git a/backend/core/models/schedules/__init__.py b/backend/core/models/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/schedules/schedules.py b/backend/core/models/schedules/schedules.py new file mode 100644 index 0000000..1e3b8b8 --- /dev/null +++ b/backend/core/models/schedules/schedules.py @@ -0,0 +1,81 @@ +""" +Schedule models for managing service schedules.py. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Schedule(models.Model): + """Service schedules.py for accounts""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='schedules') + + # Service days + monday_service = models.BooleanField(default=False) + tuesday_service = models.BooleanField(default=False) + wednesday_service = models.BooleanField(default=False) + thursday_service = models.BooleanField(default=False) + friday_service = models.BooleanField(default=False) + saturday_service = models.BooleanField(default=False) + sunday_service = models.BooleanField(default=False) + weekend_service = models.BooleanField(default=False) + + # Exceptions + schedule_exception = models.TextField(blank=True, null=True) + + # Dates + start_date = models.DateField() + end_date = models.DateField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = 'core' + verbose_name = 'Schedule' + verbose_name_plural = 'Schedules' + ordering = ['-start_date'] + indexes = [ + models.Index(fields=['account']), + ] + + def __str__(self): + return f"Schedule for {self.account.name}" + + @property + def is_active(self): + """Check if schedule is active based on end_date""" + return self.end_date is None or self.end_date > timezone.now().date() + + @property + def service_days(self): + """Return list of service days""" + days = [] + if self.monday_service: + days.append('Monday') + if self.tuesday_service: + days.append('Tuesday') + if self.wednesday_service: + days.append('Wednesday') + if self.thursday_service: + days.append('Thursday') + if self.friday_service: + days.append('Friday') + if self.saturday_service: + days.append('Saturday') + if self.sunday_service: + days.append('Sunday') + return days + + def has_service_on_day(self, day_name): + """Check if there is service on a specific day""" + day_map = { + 'monday': self.monday_service, + 'tuesday': self.tuesday_service, + 'wednesday': self.wednesday_service, + 'thursday': self.thursday_service, + 'friday': self.friday_service, + 'saturday': self.saturday_service, + 'sunday': self.sunday_service, + } + return day_map.get(day_name.lower(), False) \ No newline at end of file diff --git a/backend/core/models/services/__init__.py b/backend/core/models/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/models/services/services.py b/backend/core/models/services/services.py new file mode 100644 index 0000000..a20d2d3 --- /dev/null +++ b/backend/core/models/services/services.py @@ -0,0 +1,71 @@ +""" +Service models for tracking service events. +""" +import uuid +from django.db import models +from django.utils import timezone + + +class Service(models.Model): + """Service records for accounts""" + STATUS_CHOICES = ( + ('scheduled', 'Scheduled'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='services') + date = models.DateField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled') + team_members = models.ManyToManyField('Profile', related_name='services') + notes = models.TextField(blank=True, null=True) + + # Service window + deadline_start = models.DateTimeField() + deadline_end = models.DateTimeField() + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(blank=True, null=True) + + class Meta: + app_label = 'core' + verbose_name = 'Service' + verbose_name_plural = 'Services' + ordering = ['-date'] + indexes = [ + models.Index(fields=['account']), + models.Index(fields=['date']), + models.Index(fields=['status']), + ] + + def __str__(self): + return f"Service for {self.account.name} on {self.date}" + + def save(self, *args, **kwargs): + """Override save to set completed_at when status changes to completed""" + if self.status == 'completed' and not self.completed_at: + self.completed_at = timezone.now() + super().save(*args, **kwargs) + + @property + def is_upcoming(self): + """Check if service is upcoming""" + return self.date > timezone.now().date() + + @property + def is_today(self): + """Check if service is scheduled for today""" + return self.date == timezone.now().date() + + @property + def is_past_due(self): + """Check if service is past due""" + return self.date < timezone.now().date() and self.status not in ['completed', 'cancelled'] + + @property + def team_member_names(self): + """Get list of team member names""" + return [f"{member.first_name} {member.last_name}" for member in self.team_members.all()] \ No newline at end of file diff --git a/backend/core/repositories/__init__.py b/backend/core/repositories/__init__.py new file mode 100644 index 0000000..2847761 --- /dev/null +++ b/backend/core/repositories/__init__.py @@ -0,0 +1,31 @@ +""" +Repositories package initialization. +Import all repositories here to make them available when importing from core.repositories +""" +from backend.core.repositories.base import BaseRepository +from backend.core.repositories.profiles.profiles import ProfileRepository +from backend.core.repositories.customers.customers import CustomerRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.revenues.revenues import RevenueRepository +from backend.core.repositories.labor.labor import LaborRepository +from backend.core.repositories.schedules.schedules import ScheduleRepository +from backend.core.repositories.services.services import ServiceRepository +from backend.core.repositories.projects.projects import ProjectRepository +from backend.core.repositories.invoices.invoices import InvoiceRepository +from backend.core.repositories.reports.reports import ReportRepository +from backend.core.repositories.punchlists.punchlists import PunchlistRepository + +__all__ = [ + 'BaseRepository', + 'ProfileRepository', + 'CustomerRepository', + 'AccountRepository', + 'RevenueRepository', + 'LaborRepository', + 'ScheduleRepository', + 'ServiceRepository', + 'ProjectRepository', + 'InvoiceRepository', + 'ReportRepository', + 'PunchlistRepository', +] diff --git a/backend/core/repositories/accounts/__init__.py b/backend/core/repositories/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/accounts/accounts.py b/backend/core/repositories/accounts/accounts.py new file mode 100644 index 0000000..acb07fa --- /dev/null +++ b/backend/core/repositories/accounts/accounts.py @@ -0,0 +1,220 @@ +""" +Repository for Account model operations. +""" +from typing import Optional +from django.db.models import Q, Prefetch +from django.utils import timezone +from backend.core.models import Account, Service +from backend.core.repositories.base import BaseRepository + + +class AccountRepository(BaseRepository[Account]): + """ + Repository for Account model operations. + """ + model = Account + + @classmethod + def get_by_customer(cls, customer_id: str): + """ + Get accounts by customer. + + Args: + customer_id: The customer ID + + Returns: + QuerySet of accounts for the customer + """ + return Account.objects.filter(customer_id=customer_id) + + @classmethod + def get_active(cls): + """ + Get all active accounts. + + Returns: + QuerySet of active accounts + """ + return Account.objects.filter( + Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date()) + ) + + @classmethod + def get_active_by_customer(cls, customer_id: str): + """ + Get active accounts by customer. + + Args: + customer_id: The customer ID + + Returns: + QuerySet of active accounts for the customer + """ + return Account.objects.filter( + customer_id=customer_id + ).filter( + Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date()) + ) + + @classmethod + def get_inactive(cls): + """ + Get all inactive accounts. + + Returns: + QuerySet of inactive accounts + """ + return Account.objects.filter( + end_date__isnull=False, + end_date__lte=timezone.now().date() + ) + + @classmethod + def mark_inactive(cls, id: str) -> Optional[Account]: + """ + Mark an account as inactive. + + Args: + id: The account ID + + Returns: + The updated account or None if not found + """ + return cls.update(id, {'end_date': timezone.now().date()}) + + @classmethod + def search(cls, search_term: str, include_inactive: bool = False): + """ + Search accounts by name or contact info. + + Args: + search_term: The search term + include_inactive: Whether to include inactive accounts + + Returns: + QuerySet of matching accounts + """ + queryset = super().search( + search_term, + [ + 'name', + 'primary_contact_first_name', + 'primary_contact_last_name', + 'primary_contact_email', + 'secondary_contact_first_name', + 'secondary_contact_last_name', + 'secondary_contact_email', + 'customer__name' + ] + ) + + if not include_inactive: + queryset = queryset.filter( + Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date()) + ) + + return queryset + + @classmethod + def get_with_services(cls, id: str, upcoming_only: bool = False) -> Optional[Account]: + """ + Get an account with prefetched services. + + Args: + id: The account ID + upcoming_only: Whether to include only upcoming services + + Returns: + The account with services or None if not found + """ + services_queryset = Service.objects.all() + + if upcoming_only: + services_queryset = services_queryset.filter(date__gte=timezone.now().date()) + + services_prefetch = Prefetch('services', queryset=services_queryset) + + try: + return Account.objects.prefetch_related(services_prefetch).get(pk=id) + except Account.DoesNotExist: + return None + + @classmethod + def get_with_revenues(cls, id: str) -> Optional[Account]: + """ + Get an account with prefetched revenues. + + Args: + id: The account ID + + Returns: + The account with revenues or None if not found + """ + try: + return Account.objects.prefetch_related('revenues').get(pk=id) + except Account.DoesNotExist: + return None + + @classmethod + def get_with_labors(cls, id: str) -> Optional[Account]: + """ + Get an account with prefetched labors. + + Args: + id: The account ID + + Returns: + The account with labors or None if not found + """ + try: + return Account.objects.prefetch_related('labors').get(pk=id) + except Account.DoesNotExist: + return None + + @classmethod + def get_with_schedules(cls, id: str) -> Optional[Account]: + """ + Get an account with prefetched schedules + + Args: + id: The account ID + + Returns: + The account with schedules or None if not found + """ + try: + return Account.objects.prefetch_related('schedules').get(pk=id) + except Account.DoesNotExist: + return None + + @classmethod + def get_with_projects(cls, id: str) -> Optional[Account]: + """ + Get an account with prefetched projects. + Args: + id: The account ID + Returns: + The account with projects or None if not found + """ + try: + return Account.objects.prefetch_related('projects').get(pk=id) + except Account.DoesNotExist: + return None + + @classmethod + def get_with_all_related(cls, id: str) -> Optional[Account]: + """ + Get an account with all related data prefetched. + + Args: + id: The account ID + + Returns: + The account with all related data or None if not found + """ + try: + return Account.objects.prefetch_related( + 'services', 'revenues', 'labors', 'schedules.py', 'projects' + ).select_related('customer').get(pk=id) + except Account.DoesNotExist: + return None \ No newline at end of file diff --git a/backend/core/repositories/base.py b/backend/core/repositories/base.py new file mode 100644 index 0000000..43c7f96 --- /dev/null +++ b/backend/core/repositories/base.py @@ -0,0 +1,209 @@ +""" +Base repository class for data access. +""" +from typing import TypeVar, Generic, List, Dict, Any, Optional, Type +from django.db import models +from django.db.models import Q, QuerySet + +T = TypeVar('T', bound=models.Model) + + +class BaseRepository(Generic[T]): + """ + Base repository class with common methods for data access. + """ + model: Type[T] = None + + @classmethod + def get_by_id(cls, entity_id: str) -> Optional[T]: + """ + Get an entity by ID. + + Args: + entity_id: The entity ID + + Returns: + The entity or None if not found + """ + try: + return cls.model.objects.get(pk=entity_id) + except cls.model.DoesNotExist: + return None + + @classmethod + def get_all(cls, **filters) -> QuerySet[T]: + """ + Get all entities with optional filtering. + + Args: + **filters: Filter parameters + + Returns: + QuerySet of matching entities + """ + queryset = cls.model.objects.all() + + # Apply filters + for key, value in filters.items(): + if value is not None: + # Handle special filter keys + if key.endswith('__in') and not value: + # Empty list for __in lookup should return empty queryset + return cls.model.objects.none() + + # Apply the filter + queryset = queryset.filter(**{key: value}) + + return queryset + + @classmethod + def create(cls, data: Dict[str, Any]) -> T: + """ + Create a new entity. + + Args: + data: Entity data + + Returns: + The created entity + """ + return cls.model.objects.create(**data) + + @classmethod + def update(cls, entity_id: str, data: Dict[str, Any]) -> Optional[T]: + """ + Update an existing entity. + + Args: + entity_id: The entity ID + data: Updated data + + Returns: + The updated entity or None if not found + """ + obj = cls.get_by_id(entity_id) + if not obj: + return None + + for key, value in data.items(): + setattr(obj, key, value) + + obj.save() + return obj + + @classmethod + def delete(cls, entity_id: str) -> bool: + """ + Delete an entity. + + Args: + entity_id: The entity ID + + Returns: + True if deleted, False if not found + """ + obj = cls.get_by_id(entity_id) + if not obj: + return False + + obj.delete() + return True + + @classmethod + def bulk_create(cls, data_list: List[Dict[str, Any]]) -> List[T]: + """ + Create multiple entities. + + Args: + data_list: List of entity data + + Returns: + List of created entities + """ + objects = [cls.model(**data) for data in data_list] + return cls.model.objects.bulk_create(objects) + + @classmethod + def bulk_update(cls, objects: List[T], fields: List[str]) -> int: + """ + Update multiple entities. + + Args: + objects: List of entity objects + fields: List of fields to update + + Returns: + Number of updated entities + """ + # Cast the objects to Any to bypass type checking for this call + return cls.model.objects.bulk_update(objects, fields) # type: ignore + + @classmethod + def count(cls, **filters) -> int: + """ + Count entities with optional filtering. + + Args: + **filters: Filter parameters + + Returns: + Count of matching entities + """ + return cls.get_all(**filters).count() + + @classmethod + def exists(cls, **filters) -> bool: + """ + Check if any entities exist with optional filtering. + + Args: + **filters: Filter parameters + + Returns: + True if entities exist, False otherwise + """ + return cls.get_all(**filters).exists() + + @classmethod + def filter_by_date_range(cls, start_date=None, end_date=None, date_field='date') -> QuerySet[T]: + """ + Filter entities by date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + date_field: Name of the date field to filter on + + Returns: + QuerySet of matching entities + """ + filters = {} + + if start_date: + filters[f'{date_field}__gte'] = start_date + + if end_date: + filters[f'{date_field}__lte'] = end_date + + return cls.get_all(**filters) + + @classmethod + def search(cls, search_term: str, search_fields: List[str]) -> QuerySet[T]: + """ + Search entities by term across multiple fields. + + Args: + search_term: The search term + search_fields: List of fields to search in + + Returns: + QuerySet of matching entities + """ + if not search_term: + return cls.model.objects.all() + + q_objects = Q() + for field in search_fields: + q_objects |= Q(**{f'{field}__icontains': search_term}) + + return cls.model.objects.filter(q_objects) \ No newline at end of file diff --git a/backend/core/repositories/customers/__init__.py b/backend/core/repositories/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/customers/customers.py b/backend/core/repositories/customers/customers.py new file mode 100644 index 0000000..e48c886 --- /dev/null +++ b/backend/core/repositories/customers/customers.py @@ -0,0 +1,177 @@ +""" +Repository for Customer model operations. +""" +from typing import Optional +from django.db.models import Q, QuerySet +from django.utils import timezone +from backend.core.repositories.base import BaseRepository +from backend.core.models import Customer + + +class CustomerRepository(BaseRepository[Customer]): + """ + Repository for Customer model operations. + """ + model = Customer + + @classmethod + def get_active(cls): + """ + Get all active customers. + + Returns: + QuerySet of active customers + """ + return Customer.objects.filter( + Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date()) + ) + + @classmethod + def get_inactive(cls): + """ + Get all inactive customers. + + Returns: + QuerySet of inactive customers + """ + return Customer.objects.filter( + end_date__isnull=False, + end_date__lte=timezone.now().date() + ) + + @classmethod + def mark_inactive(cls, customer_id: str) -> Optional[Customer]: + """ + Mark a customer as inactive. + + Args: + customer_id: The customer ID + + Returns: + The updated customer or None if not found + """ + return cls.update(customer_id, {'end_date': timezone.now().date()}) + + @classmethod + def search(cls, search_term: str, include_inactive: bool = False): + """ + Search customers by name or contact info. + + Args: + search_term: The search term + include_inactive: Whether to include inactive customers + + Returns: + QuerySet of matching customers + """ + queryset = super().search( + search_term, + [ + 'name', + 'primary_contact_first_name', + 'primary_contact_last_name', + 'primary_contact_email', + 'secondary_contact_first_name', + 'secondary_contact_last_name', + 'secondary_contact_email', + 'billing_contact_first_name', + 'billing_contact_last_name', + 'billing_email' + ] + ) + + if not include_inactive: + queryset = queryset.filter( + Q(end_date__isnull=True) | Q(end_date__gt=timezone.now().date()) + ) + + return queryset + + @classmethod + def get_by_email(cls, email: str) -> Optional[Customer]: + """ + Get a customer by email. + + Args: + email: The email address + + Returns: + The customer or None if not found + """ + return Customer.objects.filter( + Q(primary_contact_email=email) | + Q(secondary_contact_email=email) | + Q(billing_email=email) + ).first() + + @classmethod + def get_with_accounts(cls, customer_id: str) -> Optional[Customer]: + """ + Get a customer with prefetched accounts. + + Args: + customer_id: The customer ID + + Returns: + The customer with accounts or None if not found + """ + try: + return Customer.objects.prefetch_related('accounts').get(pk=customer_id) + except Customer.DoesNotExist: + return None + + @classmethod + def get_with_projects(cls, customer_id: str) -> Optional[Customer]: + """ + Get a customer with prefetched projects. + + Args: + customer_id: The customer ID + + Returns: + The customer with projects or None if not found + """ + try: + return Customer.objects.prefetch_related('projects').get(pk=customer_id) + except Customer.DoesNotExist: + return None + + @classmethod + def get_with_invoices(cls, customer_id: str) -> Optional[Customer]: + """ + Get a customer with prefetched invoices. + + Args: + customer_id: The customer ID + + Returns: + The customer with invoices or None if not found + """ + try: + return Customer.objects.prefetch_related('invoices').get(pk=customer_id) + except Customer.DoesNotExist: + return None + + @classmethod + def filter_customers(cls, name=None, city=None, state=None, start_date=None, end_date=None) -> QuerySet[Customer]: + """ + Filter customers by multiple criteria. + """ + queryset = Customer.objects.all() + + if name: + queryset = queryset.filter(name__icontains=name) + + if city: + queryset = queryset.filter(billing_city__icontains=city) + + if state: + queryset = queryset.filter(billing_state__iexact=state) + + if start_date: + queryset = queryset.filter(start_date__gte=start_date) + + if end_date: + queryset = queryset.filter(end_date__lte=end_date) + + return queryset diff --git a/backend/core/repositories/invoices/__init__.py b/backend/core/repositories/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/invoices/invoices.py b/backend/core/repositories/invoices/invoices.py new file mode 100644 index 0000000..b02747a --- /dev/null +++ b/backend/core/repositories/invoices/invoices.py @@ -0,0 +1,302 @@ +""" +Repository for Invoice model operations. +""" +from typing import List, Optional, Dict, Any +from datetime import date, timedelta +from django.db.models import QuerySet, Sum +from django.utils import timezone +from backend.core.models import Invoice, Account, Project +from backend.core.repositories.base import BaseRepository + + +class InvoiceRepository(BaseRepository[Invoice]): + """ + Repository for Invoice model operations. + """ + model = Invoice + + @classmethod + def get_by_customer(cls, customer_id: str) -> QuerySet[Invoice]: + """ + Get invoices by customer. + + Args: + customer_id: The customer ID + + Returns: + QuerySet of invoices for the customer + """ + return Invoice.objects.filter(customer_id=customer_id) + + @classmethod + def get_by_status(cls, status: str) -> QuerySet[Invoice]: + """ + Get invoices by status. + + Args: + status: The invoice status + + Returns: + QuerySet of invoices with the specified status + """ + return Invoice.objects.filter(status=status) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Invoice]: + """ + Get invoices within a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of invoices within the date range + """ + return cls.filter_by_date_range(start_date, end_date) + + @classmethod + def get_overdue(cls) -> QuerySet[Invoice]: + """ + Get overdue invoices. + + Returns: + QuerySet of overdue invoices + """ + thirty_days_ago = timezone.now().date() - timedelta(days=30) + return Invoice.objects.filter( + status__in=['sent', 'overdue'], # Include both sent and already marked overdue + sent_at__isnull=False, # Must have been sent + ).exclude( + status__in=['paid', 'cancelled'] # Not paid or cancelled + ).filter( + sent_at__date__lt=thirty_days_ago # Sent more than 30 days ago + ) + + @classmethod + def get_unpaid(cls) -> QuerySet[Invoice]: + """ + Get unpaid invoices. + + Returns: + QuerySet of unpaid invoices (sent but not paid) + """ + return Invoice.objects.filter(status='sent') + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Invoice]: + """ + Search invoices by customer. + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching invoices + """ + return super().search( + search_term, + ['customer__name'] + ) + + @classmethod + def filter_invoices( + cls, + customer_id: str = None, + status: str = None, + date_from: date = None, + date_to: date = None, + account_id: str = None, + project_id: str = None + ) -> QuerySet[Invoice]: + """ + Filter invoices by multiple criteria. + + Args: + customer_id: Filter by customer ID + status: Filter by status + date_from: Filter by start date (inclusive) + date_to: Filter by end date (inclusive) + account_id: Filter by account ID + project_id: Filter by project ID + + Returns: + QuerySet of matching invoices + """ + queryset = Invoice.objects.all() + + if customer_id: + queryset = queryset.filter(customer_id=customer_id) + + if status: + queryset = queryset.filter(status=status) + + if date_from: + queryset = queryset.filter(date__gte=date_from) + + if date_to: + queryset = queryset.filter(date__lte=date_to) + + if account_id: + queryset = queryset.filter(accounts__id=account_id) + + if project_id: + queryset = queryset.filter(projects__id=project_id) + + return queryset + + @classmethod + def create_with_items( + cls, + data: Dict[str, Any], + account_ids: List[str] = None, + project_ids: List[str] = None + ) -> Invoice: + """ + Create an invoice with related items. + + Args: + data: Invoice data + account_ids: List of account IDs + project_ids: List of project IDs + + Returns: + The created invoice + """ + # Create the invoice + invoice = cls.create(data) + + # Add accounts + if account_ids: + accounts = Account.objects.filter(id__in=account_ids) + invoice.accounts.set(accounts) + + # Add projects + if project_ids: + projects = Project.objects.filter(id__in=project_ids) + invoice.projects.set(projects) + + return invoice + + @classmethod + def update_status(cls, invoice_id: str, status: str) -> Optional[Invoice]: + """ + Update invoice status. + + Args: + invoice_id: The invoice ID + status: The new status + + Returns: + The updated invoice or None if not found + """ + invoice = cls.get_by_id(invoice_id) + if not invoice: + return None + + invoice.status = status + + # Set sent_at if status is 'sent' + if status == 'sent' and not invoice.sent_at: + invoice.sent_at = timezone.now() + + # Set date_paid if status is 'paid' + if status == 'paid' and not invoice.date_paid: + invoice.date_paid = timezone.now().date() + + invoice.save() + return invoice + + @classmethod + def mark_as_paid(cls, invoice_id: str, payment_type: str) -> Optional[Invoice]: + """ + Mark an invoice as paid. + + Args: + invoice_id: The invoice ID + payment_type: The payment type + + Returns: + The updated invoice or None if not found + """ + invoice = cls.get_by_id(invoice_id) + if not invoice: + return None + + invoice.status = 'paid' + invoice.date_paid = timezone.now().date() + invoice.payment_type = payment_type + invoice.save() + + return invoice + + @classmethod + def get_total_paid(cls, customer_id: str = None, date_from: date = None, date_to: date = None) -> float: + """ + Get total paid invoice amount. + + Args: + customer_id: Filter by customer ID + date_from: Filter by start date (inclusive) + date_to: Filter by end date (inclusive) + + Returns: + Total paid amount + """ + queryset = Invoice.objects.filter(status='paid') + + if customer_id: + queryset = queryset.filter(customer_id=customer_id) + + if date_from: + queryset = queryset.filter(date_paid__gte=date_from) + + if date_to: + queryset = queryset.filter(date_paid__lte=date_to) + + result = queryset.aggregate(total=Sum('total_amount')) + return float(result['total'] or 0) + + @classmethod + def get_total_outstanding(cls, customer_id: str = None) -> float: + """ + Get total outstanding invoice amount. + + Args: + customer_id: Filter by customer ID + + Returns: + Total outstanding amount + """ + queryset = Invoice.objects.filter(status__in=['sent', 'overdue']) + + if customer_id: + queryset = queryset.filter(customer_id=customer_id) + + result = queryset.aggregate(total=Sum('total_amount')) + return float(result['total'] or 0) + + @classmethod + def mark_overdue(cls, invoice_id: str = None) -> int: + """ + Mark invoice(s) as overdue. + + Args: + invoice_id: Optional invoice ID. If not provided, all overdue invoices will be marked. + + Returns: + Number of invoices marked as overdue + """ + thirty_days_ago = timezone.now().date() - timedelta(days=30) + query = Invoice.objects.filter( + status='sent', + sent_at__isnull=False, + sent_at__date__lt=thirty_days_ago + ) + + if invoice_id: + query = query.filter(id=invoice_id) + + count = query.update(status='overdue') + return count \ No newline at end of file diff --git a/backend/core/repositories/labor/__init__.py b/backend/core/repositories/labor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/labor/labor.py b/backend/core/repositories/labor/labor.py new file mode 100644 index 0000000..5824b78 --- /dev/null +++ b/backend/core/repositories/labor/labor.py @@ -0,0 +1,183 @@ +""" +Repository for Labor model operations. +""" +from typing import Optional, List +from datetime import date +from django.db.models import QuerySet, Q +from django.utils import timezone +from backend.core.models import Labor +from backend.core.repositories.base import BaseRepository + + +class LaborRepository(BaseRepository[Labor]): + """ + Repository for Labor model operations. + """ + model = Labor + + @classmethod + def get_by_account(cls, account_id: str) -> QuerySet[Labor]: + """ + Get labors by account. + + Args: + account_id: The account ID + + Returns: + QuerySet of labors for the account + """ + return Labor.objects.filter(account_id=account_id) + + @classmethod + def get_active(cls) -> QuerySet[Labor]: + """ + Get active labors. + + Returns: + QuerySet of active labors + """ + current_date = timezone.now().date() + return Labor.objects.filter( + start_date__lte=current_date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=current_date) + ) + + @classmethod + def get_active_by_account(cls, account_id: str) -> Optional[Labor]: + """ + Get active labor for an account. + + Args: + account_id: The account ID + + Returns: + Active labor for the account or None if not found + """ + current_date = timezone.now().date() + return Labor.objects.filter( + account_id=account_id, + start_date__lte=current_date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=current_date) + ).first() + + @classmethod + def end_labor(cls, labor_id: str) -> Optional[Labor]: + """ + End a labor record by setting its end date to today. + + Args: + labor_id: The labor ID + + Returns: + The updated labor or None if not found + """ + return cls.update(labor_id, {'end_date': timezone.now().date()}) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Labor]: + """ + Get labors that were active during a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of labors active during the date range + """ + queryset = Labor.objects.all() + + if start_date: + # Exclude labors that ended before the start date + queryset = queryset.exclude( + end_date__isnull=False, + end_date__lt=start_date + ) + + if end_date: + # Exclude labors that started after the end date + queryset = queryset.exclude(start_date__gt=end_date) + + return queryset + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Labor]: + """ + Search labor records. + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching labor records + """ + return super().search( + search_term, + ['account__name'] + ) + + @classmethod + def get_total_labor_cost(cls, account_id: str = None, start_date: date = None, end_date: date = None) -> float: + """ + Get total labor cost for an account or all accounts within a date range. + + Args: + account_id: Optional account ID to filter by + start_date: Optional start date for the period + end_date: Optional end date for the period + + Returns: + Total labor cost + """ + labors = cls.get_by_date_range(start_date, end_date) + + if account_id: + labors = labors.filter(account_id=account_id) + + # Calculate proportional costs for labors that span beyond the range + total_cost = 0 + + for labor in labors: + # Get the effective start and end dates for the calculation + # (intersection of labor period and requested period) + effective_start = max(labor.start_date, start_date) if start_date else labor.start_date + + current_date = timezone.now().date() + if labor.end_date: + effective_end = min(labor.end_date, end_date) if end_date else labor.end_date + else: + effective_end = end_date if end_date else current_date + + # Calculate days in range + days_in_range = (effective_end - effective_start).days + 1 + + # Calculate total days for labor period + if labor.end_date: + total_days = (labor.end_date - labor.start_date).days + 1 + else: + total_days = (current_date - labor.start_date).days + 1 + + # Avoid division by zero + if total_days <= 0: + total_days = 1 + + # Calculate proportional cost + proportional_cost = labor.amount * (days_in_range / total_days) + + total_cost += proportional_cost + + return float(total_cost) + + @classmethod + def get_inactive(cls) -> QuerySet[Labor]: + """ + Get inactive labors. + + Returns: + QuerySet of inactive labors + """ + current_date = timezone.now().date() + return Labor.objects.filter(end_date__lt=current_date) diff --git a/backend/core/repositories/profiles/__init__.py b/backend/core/repositories/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/profiles/profiles.py b/backend/core/repositories/profiles/profiles.py new file mode 100644 index 0000000..2414717 --- /dev/null +++ b/backend/core/repositories/profiles/profiles.py @@ -0,0 +1,103 @@ +""" +Repository for Profile model operations. +""" +from typing import Optional, List +from django.contrib.auth.models import User +from django.db.models import QuerySet + +from backend.core.models import Profile +from backend.core.repositories.base import BaseRepository + + +class ProfileRepository(BaseRepository[Profile]): + """ + Repository for Profile model operations. + """ + model = Profile + + @classmethod + def get_by_user(cls, user: User) -> Optional[Profile]: + """ + Get a profile by user. + + Args: + user: The user + + Returns: + The profile or None if not found + """ + try: + return Profile.objects.get(user=user) + except Profile.DoesNotExist: + return None + + @classmethod + def get_by_email(cls, email: str) -> Optional[Profile]: + """ + Get a profile by email. + + Args: + email: The email address + + Returns: + The profile or None if not found + """ + return Profile.objects.filter(email=email).first() + + @classmethod + def get_by_role(cls, role: str) -> QuerySet[Profile]: + """ + Get profiles by role. + + Args: + role: The role + + Returns: + List of profiles with the specified role + """ + return Profile.objects.filter(role=role) + + @classmethod + def get_admins(cls) -> QuerySet[Profile]: + """ + Get all admin profiles + + Returns: + List of admin profiles + """ + return cls.get_by_role('admin') + + @classmethod + def get_team_leaders(cls) -> QuerySet[Profile]: + """ + Get all team leader profiles + + Returns: + List of team leader profiles + """ + return cls.get_by_role('team_leader') + + @classmethod + def get_team_members(cls) -> QuerySet[Profile]: + """ + Get all team member profiles + + Returns: + List of team member profiles + """ + return cls.get_by_role('team_member') + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Profile]: + """ + Search profiles by name or email. + + Args: + search_term: The search term + search_fields: List of fields to search in (optional, default fields will be used if not provided) + + Returns: + QuerySet of matching profiles + """ + fields = search_fields or ['first_name', 'last_name', 'email', 'user__username', 'user__email'] + return super().search(search_term, fields) \ No newline at end of file diff --git a/backend/core/repositories/projects/__init__.py b/backend/core/repositories/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/projects/projects.py b/backend/core/repositories/projects/projects.py new file mode 100644 index 0000000..27dfc36 --- /dev/null +++ b/backend/core/repositories/projects/projects.py @@ -0,0 +1,266 @@ +""" +Repository for Project model operations. +""" +from typing import List, Optional, Dict, Any +from datetime import date +from django.db.models import QuerySet +from django.utils import timezone +from backend.core.models import Project, Profile +from backend.core.repositories.base import BaseRepository + + +class ProjectRepository(BaseRepository[Project]): + """ + Repository for Project model operations. + """ + model = Project + + @classmethod + def get_by_customer(cls, customer_id: str) -> QuerySet[Project]: + """ + Get projects by customer. + + Args: + customer_id: The customer ID + + Returns: + QuerySet of projects for the customer + """ + return Project.objects.filter(customer_id=customer_id) + + @classmethod + def get_by_account(cls, account_id: str) -> QuerySet[Project]: + """ + Get projects by account. + + Args: + account_id: The account ID + + Returns: + QuerySet of projects for the account + """ + return Project.objects.filter(account_id=account_id) + + @classmethod + def get_by_team_member(cls, profile_id: str) -> QuerySet[Project]: + """ + Get projects by team member. + + Args: + profile_id: The profile ID + + Returns: + QuerySet of projects assigned to the team member + """ + return Project.objects.filter(team_members__id=profile_id) + + @classmethod + def get_by_status(cls, status: str) -> QuerySet[Project]: + """ + Get projects by status. + + Args: + status: The project status + + Returns: + QuerySet of projects with the specified status + """ + return Project.objects.filter(status=status) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Project]: + """ + Get projects within a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of projects within the date range + """ + return cls.filter_by_date_range(start_date, end_date) + + @classmethod + def get_upcoming(cls) -> QuerySet[Project]: + """ + Get upcoming projects. + + Returns: + QuerySet of upcoming projects + """ + return Project.objects.filter(date__gte=timezone.now().date()) + + @classmethod + def get_past_due(cls) -> QuerySet[Project]: + """ + Get past due projects. + + Returns: + QuerySet of past due projects + """ + return Project.objects.filter( + date__lt=timezone.now().date(), + status__in=['planned', 'in_progress'] + ) + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Project]: + """ + Search projects by customer, account, or notes. + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching projects + """ + return super().search( + search_term, + ['customer__name', 'account__name', 'notes'] + ) + + @classmethod + def filter_projects( + cls, + customer_id: str = None, + account_id: str = None, + status: str = None, + date_from: date = None, + date_to: date = None, + team_member_id: str = None + ) -> QuerySet[Project]: + """ + Filter projects by multiple criteria. + + Args: + customer_id: Filter by customer ID + account_id: Filter by account ID + status: Filter by status + date_from: Filter by start date (inclusive) + date_to: Filter by end date (inclusive) + team_member_id: Filter by team member ID + + Returns: + QuerySet of matching projects + """ + queryset = Project.objects.all() + + if customer_id: + queryset = queryset.filter(customer_id=customer_id) + + if account_id: + queryset = queryset.filter(account_id=account_id) + + if status: + queryset = queryset.filter(status=status) + + if date_from: + queryset = queryset.filter(date__gte=date_from) + + if date_to: + queryset = queryset.filter(date__lte=date_to) + + if team_member_id: + queryset = queryset.filter(team_members__id=team_member_id) + + return queryset + + @classmethod + def create_with_team_members(cls, data: Dict[str, Any], team_member_ids: List[str]) -> Project: + """ + Create a project with team members. + + Args: + data: Project data + team_member_ids: List of team member IDs + + Returns: + The created project + """ + # Create the project + project = cls.create(data) + + # Add team members + if team_member_ids: + team_members = Profile.objects.filter(id__in=team_member_ids) + project.team_members.set(team_members) + + return project + + @classmethod + def update_status(cls, project_id: str, status: str) -> Optional[Project]: + """ + Update project status. + + Args: + project_id: The project ID + status: The new status + + Returns: + The updated project or None if not found + """ + project = cls.get_by_id(project_id) + if not project: + return None + + project.status = status + + # Set completed_at if status is 'completed' + if status == 'completed' and not project.completed_at: + project.completed_at = timezone.now() + + project.save() + return project + + @classmethod + def assign_team_members(cls, project_id: str, team_member_ids: List[str]) -> Optional[Project]: + """ + Assign team members to a project. + + Args: + project_id: The project ID + team_member_ids: List of team member IDs + + Returns: + The updated project or None if not found + """ + project = cls.get_by_id(project_id) + if not project: + return None + + team_members = Profile.objects.filter(id__in=team_member_ids) + project.team_members.set(team_members) + + return project + + @classmethod + def get_without_invoice(cls) -> QuerySet[Project]: + """ + Get projects that have not been invoiced. + + Returns: + QuerySet of projects without invoices + """ + return Project.objects.filter( + status='completed', + invoices__isnull=True + ) + + @classmethod + def calculate_profit(cls, project_id: str) -> Optional[float]: + """ + Calculate profit for a project. + + Args: + project_id: The project ID + + Returns: + Project profit or None if not found + """ + project = cls.get_by_id(project_id) + if not project: + return None + + return float(project.amount - project.labor) \ No newline at end of file diff --git a/backend/core/repositories/punchlists/__init__.py b/backend/core/repositories/punchlists/__init__.py new file mode 100644 index 0000000..289f9fb --- /dev/null +++ b/backend/core/repositories/punchlists/__init__.py @@ -0,0 +1,3 @@ +""" +Punchlist repositories for managing punchlist data. +""" \ No newline at end of file diff --git a/backend/core/repositories/punchlists/punchlists.py b/backend/core/repositories/punchlists/punchlists.py new file mode 100644 index 0000000..edaa70d --- /dev/null +++ b/backend/core/repositories/punchlists/punchlists.py @@ -0,0 +1,98 @@ +""" +Repository for Punchlist model. +""" +from typing import Dict, Any +from datetime import date +from django.db.models import QuerySet +from django.utils import timezone + +from backend.core.models import Punchlist, Project, Account +from backend.core.repositories.base import BaseRepository + + +class PunchlistRepository(BaseRepository[Punchlist]): + """Repository for Punchlist model""" + model = Punchlist + + @classmethod + def get_by_project(cls, project_id: str) -> QuerySet: + """ + Get punchlists by project ID + """ + return cls.model.objects.filter(project_id=project_id) + + @classmethod + def get_by_account(cls, account_id: str) -> QuerySet: + """ + Get punchlists by account ID + """ + return cls.model.objects.filter(account_id=account_id) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet: + """ + Get punchlists by date range + """ + queryset = cls.model.objects.all() + + if start_date: + queryset = queryset.filter(date__gte=start_date) + + if end_date: + queryset = queryset.filter(date__lte=end_date) + + return queryset + + @classmethod + def filter_punchlists(cls, project_id: str = None, account_id: str = None, + date_from: date = None, date_to: date = None) -> QuerySet: + """ + Filter punchlists by multiple criteria + """ + queryset = cls.model.objects.all() + + if project_id: + queryset = queryset.filter(project_id=project_id) + + if account_id: + queryset = queryset.filter(account_id=account_id) + + if date_from: + queryset = queryset.filter(date__gte=date_from) + + if date_to: + queryset = queryset.filter(date__lte=date_to) + + return queryset + + @classmethod + def create_punchlist(cls, data: Dict[str, Any]) -> Punchlist: + """ + Create a punchlist for an account. + Add any custom validation logic here. + """ + return cls.create(data) + + @classmethod + def update_punchlist(cls, punchlist_id: str, data: Dict[str, Any]) -> Punchlist: + """ + Update a punchlist + """ + return cls.update(punchlist_id, data) + + @classmethod + def mark_exported(cls, punchlist_id: str, sheet_url: str = None, pdf_url: str = None) -> Punchlist: + """ + Mark a punchlist as exported + """ + punchlist = cls.get_by_id(punchlist_id) + punchlist.exported_at = timezone.now() + + if sheet_url: + punchlist.sheet_url = sheet_url + + if pdf_url: + punchlist.pdf_url = pdf_url + + punchlist.save() + return punchlist diff --git a/backend/core/repositories/reports/__init__.py b/backend/core/repositories/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/reports/reports.py b/backend/core/repositories/reports/reports.py new file mode 100644 index 0000000..fbec502 --- /dev/null +++ b/backend/core/repositories/reports/reports.py @@ -0,0 +1,235 @@ +""" +Repository for Report model operations. +""" +from typing import List, Dict, Any, Optional +from datetime import date +from django.db.models import QuerySet +from django.utils import timezone +from backend.core.models import Report, Service, Project +from backend.core.repositories.base import BaseRepository + + +class ReportRepository(BaseRepository[Report]): + """ + Repository for Report model operations. + """ + model = Report + + @classmethod + def get_by_team_member(cls, profile_id: str) -> QuerySet[Report]: + """ + Get reports by team member. + + Args: + profile_id: The profile ID + + Returns: + QuerySet of reports by the team member + """ + return Report.objects.filter(team_member_id=profile_id) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Report]: + """ + Get reports within a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of reports within the date range + """ + return cls.filter_by_date_range(start_date, end_date) + + @classmethod + def create_with_items( + cls, + data: Dict[str, Any], + service_ids: List[str] = None, + project_ids: List[str] = None + ) -> Report: + """ + Create a report with related items. + + Args: + data: Report data + service_ids: List of service IDs + project_ids: List of project IDs + + Returns: + The created report + """ + # Create the report + report = cls.create(data) + + # Add services + if service_ids: + services = Service.objects.filter(id__in=service_ids) + report.services.set(services) + + # Add projects + if project_ids: + projects = Project.objects.filter(id__in=project_ids) + report.projects.set(projects) + + return report + + @classmethod + def get_team_member_activity( + cls, + profile_id: str, + start_date: date = None, + end_date: date = None + ) -> Dict[str, int]: + """ + Get activity summary for a team member. + + Args: + profile_id: The profile ID + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + Dictionary with activity summary + """ + # Set default date range to current month if not provided + if not start_date: + today = timezone.now().date() + start_date = date(today.year, today.month, 1) + + if not end_date: + end_date = timezone.now().date() + + # Get services for the team member + services = Service.objects.filter( + team_members__id=profile_id, + date__gte=start_date, + date__lte=end_date + ) + + # Get projects for the team member + projects = Project.objects.filter( + team_members__id=profile_id, + date__gte=start_date, + date__lte=end_date + ) + + # Get reports for the team member + reports = Report.objects.filter( + team_member_id=profile_id, + date__gte=start_date, + date__lte=end_date + ) + + # Count by status + services_by_status = { + 'scheduled': services.filter(status='scheduled').count(), + 'in_progress': services.filter(status='in_progress').count(), + 'completed': services.filter(status='completed').count(), + 'cancelled': services.filter(status='cancelled').count() + } + + projects_by_status = { + 'planned': projects.filter(status='planned').count(), + 'in_progress': projects.filter(status='in_progress').count(), + 'completed': projects.filter(status='completed').count(), + 'cancelled': projects.filter(status='cancelled').count() + } + + return { + 'total_services': services.count(), + 'total_projects': projects.count(), + 'total_reports': reports.count(), + 'services_by_status': services_by_status, + 'projects_by_status': projects_by_status + } + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Report]: + """ + Search reports. + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching reports + """ + return super().search( + search_term, + [ + 'team_member__first_name', + 'team_member__last_name', + 'team_member__email', + 'notes' + ] + ) + + @classmethod + def get_with_all_related(cls, report_id: str) -> Optional[Report]: + """ + Get a report with all related data prefetched. + + Args: + report_id: The report ID + + Returns: + The report with all related data or None if not found + """ + try: + return Report.objects.prefetch_related( + 'services', 'projects' + ).select_related('team_member').get(pk=report_id) + except Report.DoesNotExist: + return None + + @classmethod + def get_team_summary(cls, start_date: date = None, end_date: date = None) -> Dict[str, Any]: + """ + Get activity summary for all team members. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + Dictionary with team activity summary + """ + # Set default date range to current month if not provided + if not start_date: + today = timezone.now().date() + start_date = date(today.year, today.month, 1) + + if not end_date: + end_date = timezone.now().date() + + # Get all team members with reports in the date range + reports = Report.objects.filter( + date__gte=start_date, + date__lte=end_date + ).select_related('team_member') + + # Summarize by team member + summary_by_member = {} + for report in reports: + member_id = str(report.team_member.id) + member_name = f"{report.team_member.first_name} {report.team_member.last_name}" + + if member_id not in summary_by_member: + summary_by_member[member_id] = { + 'name': member_name, + 'report_count': 0, + 'service_count': 0, + 'project_count': 0 + } + + summary_by_member[member_id]['report_count'] += 1 + summary_by_member[member_id]['service_count'] += report.service_count + summary_by_member[member_id]['project_count'] += report.project_count + + return { + 'total_reports': reports.count(), + 'member_summaries': summary_by_member + } \ No newline at end of file diff --git a/backend/core/repositories/revenues/__init__.py b/backend/core/repositories/revenues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/revenues/revenues.py b/backend/core/repositories/revenues/revenues.py new file mode 100644 index 0000000..20684e0 --- /dev/null +++ b/backend/core/repositories/revenues/revenues.py @@ -0,0 +1,183 @@ +""" +Repository for Revenue model operations. +""" +from typing import Optional, List +from datetime import date +from django.db.models import QuerySet, Q +from django.utils import timezone +from backend.core.models import Revenue +from backend.core.repositories.base import BaseRepository + + +class RevenueRepository(BaseRepository[Revenue]): + """ + Repository for Revenue model operations. + """ + model = Revenue + + @classmethod + def get_by_account(cls, account_id: str) -> QuerySet[Revenue]: + """ + Get revenues by account. + + Args: + account_id: The account ID + + Returns: + QuerySet of revenues for the account + """ + return Revenue.objects.filter(account_id=account_id) + + @classmethod + def get_active(cls) -> QuerySet[Revenue]: + """ + Get active revenues. + + Returns: + QuerySet of active revenues + """ + current_date = timezone.now().date() + return Revenue.objects.filter( + start_date__lte=current_date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=current_date) + ) + + @classmethod + def get_active_by_account(cls, account_id: str) -> Optional[Revenue]: + """ + Get active revenue for an account. + + Args: + account_id: The account ID + + Returns: + Active revenue for the account or None if not found + """ + current_date = timezone.now().date() + return Revenue.objects.filter( + account_id=account_id, + start_date__lte=current_date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=current_date) + ).first() + + @classmethod + def end_revenue(cls, revenue_id: str) -> Optional[Revenue]: + """ + End a revenue record by setting its end date to today. + + Args: + revenue_id: The revenue ID + + Returns: + The updated revenue or None if not found + """ + return cls.update(revenue_id, {'end_date': timezone.now().date()}) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Revenue]: + """ + Get revenues that were active during a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of revenues active during the date range + """ + queryset = Revenue.objects.all() + + if start_date: + # Exclude revenues that ended before the start date + queryset = queryset.exclude( + end_date__isnull=False, + end_date__lt=start_date + ) + + if end_date: + # Exclude revenues that started after the end date + queryset = queryset.exclude(start_date__gt=end_date) + + return queryset + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Revenue]: + """ + Search revenue records. + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching revenue records + """ + return super().search( + search_term, + ['account__name'] + ) + + @classmethod + def get_total_revenue(cls, account_id: str = None, start_date: date = None, end_date: date = None) -> float: + """ + Get total revenue for an account or all accounts within a date range. + + Args: + account_id: Optional account ID to filter by + start_date: Optional start date for the period + end_date: Optional end date for the period + + Returns: + Total revenue + """ + revenues = cls.get_by_date_range(start_date, end_date) + + if account_id: + revenues = revenues.filter(account_id=account_id) + + # Calculate proportional revenue for records that span beyond the range + total_revenue = 0 + + for revenue in revenues: + # Get the effective start and end dates for the calculation + # (intersection of revenue period and requested period) + effective_start = max(revenue.start_date, start_date) if start_date else revenue.start_date + + current_date = timezone.now().date() + if revenue.end_date: + effective_end = min(revenue.end_date, end_date) if end_date else revenue.end_date + else: + effective_end = end_date if end_date else current_date + + # Calculate days in range + days_in_range = (effective_end - effective_start).days + 1 + + # Calculate total days for revenue period + if revenue.end_date: + total_days = (revenue.end_date - revenue.start_date).days + 1 + else: + total_days = (current_date - revenue.start_date).days + 1 + + # Avoid division by zero + if total_days <= 0: + total_days = 1 + + # Calculate proportional revenue + proportional_revenue = revenue.amount * (days_in_range / total_days) + + total_revenue += proportional_revenue + + return float(total_revenue) + + @classmethod + def get_inactive(cls) -> QuerySet[Revenue]: + """ + Get inactive revenues. + + Returns: + QuerySet of inactive revenues + """ + current_date = timezone.now().date() + return Revenue.objects.filter(end_date__lt=current_date) diff --git a/backend/core/repositories/schedules/__init__.py b/backend/core/repositories/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/schedules/schedules.py b/backend/core/repositories/schedules/schedules.py new file mode 100644 index 0000000..ee6e14e --- /dev/null +++ b/backend/core/repositories/schedules/schedules.py @@ -0,0 +1,174 @@ +""" +Repository for Schedule model operations. +""" +from datetime import datetime, date, timedelta +from typing import Optional, List +from django.db.models import QuerySet, Q +from django.utils import timezone +from backend.core.models import Schedule, Service +from backend.core.repositories.base import BaseRepository + +class ScheduleRepository(BaseRepository[Schedule]): + """ + Repository for Schedule model operations. + """ + model = Schedule + + @classmethod + def get_by_account(cls, account_id: str) -> QuerySet[Schedule]: + """ + Get schedules.py by account. + + Args: + account_id: The account ID + + Returns: + QuerySet of schedules.py for the account + """ + return Schedule.objects.filter(account_id=account_id) + + @classmethod + def get_active(cls) -> QuerySet[Schedule]: + """ + Get active schedules.py. + + Returns: + QuerySet of active schedules.py + """ + current_date = timezone.now().date() + return Schedule.objects.filter( + start_date__lte=current_date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=current_date) # Use Q directly + ) + + @classmethod + def get_active_by_account(cls, account_id: str) -> Optional[Schedule]: + """ + Get active schedule for an account. + + Args: + account_id: The account ID + + Returns: + Active schedule for the account or None if not found + """ + current_date = timezone.now().date() + return Schedule.objects.filter( + account_id=account_id, + start_date__lte=current_date + ).filter( + Q(end_date__isnull=True) | Q(end_date__gte=current_date) # Use Q directly + ).first() + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Schedule]: + """ + Search schedules + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching schedules + """ + return super().search( + search_term, + [ + 'account__name', + 'account__customer__name' + ] + ) + + @classmethod + def generate_services(cls, schedule_id: str, start_date: date, end_date: date) -> List[Service]: + """ + Generate services based on a schedule for a date range. + + Args: + schedule_id: The schedule ID + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + List of generated services + """ + + schedule = cls.get_by_id(schedule_id) + if not schedule: + return [] + + days_map = { + 0: schedule.monday_service, # Monday is 0 + 1: schedule.tuesday_service, # Tuesday is 1 + 2: schedule.wednesday_service, # Wednesday is 2 + 3: schedule.thursday_service, # Thursday is 3 + 4: schedule.friday_service, # Friday is 4 + 5: schedule.saturday_service, # Saturday is 5 + 6: schedule.sunday_service # Sunday is 6 + } + + services = [] + current_date = start_date + + # Iterate through each day in the date range + while current_date <= end_date: + weekday = current_date.weekday() + + # Check if service is scheduled for this day + if days_map[weekday] or (schedule.weekend_service and weekday >= 5): + # Create a service + service_data = { + 'account': schedule.account, # Use the account object directly + 'date': current_date, + 'status': 'scheduled', + 'deadline_start': datetime.combine( + current_date, + datetime.min.time() + ).replace(hour=9), # Default to 9:00 AM + 'deadline_end': datetime.combine( + current_date, + datetime.min.time() + ).replace(hour=17) # Default to 5:00 PM + } + + service = Service.objects.create(**service_data) + services.append(service) + + current_date += timedelta(days=1) + + return services + + @classmethod + def get_inactive(cls) -> QuerySet[Schedule]: + """ + Get inactive schedules. + + Returns: + QuerySet of inactive schedules + """ + current_date = timezone.now().date() + return Schedule.objects.filter(end_date__lt=current_date) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Schedule]: + """ + Get schedules within a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of schedules within the date range + """ + queryset = Schedule.objects.all() + + if start_date: + queryset = queryset.filter(start_date__gte=start_date) + + if end_date: + queryset = queryset.filter(end_date__lte=end_date) + + return queryset diff --git a/backend/core/repositories/services/__init__.py b/backend/core/repositories/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/repositories/services/services.py b/backend/core/repositories/services/services.py new file mode 100644 index 0000000..8194448 --- /dev/null +++ b/backend/core/repositories/services/services.py @@ -0,0 +1,228 @@ +""" +Repository for Service model operations. +""" +from typing import List, Optional, Dict, Any +from datetime import date +from django.db.models import QuerySet +from django.utils import timezone +from backend.core.models import Service, Profile +from backend.core.repositories.base import BaseRepository + + +class ServiceRepository(BaseRepository[Service]): + """ + Repository for Service model operations. + """ + model = Service + + @classmethod + def get_by_account(cls, account_id: str) -> QuerySet[Service]: + """ + Get services by account. + + Args: + account_id: The account ID + + Returns: + QuerySet of services for the account + """ + return Service.objects.filter(account_id=account_id) + + @classmethod + def get_by_team_member(cls, profile_id: str) -> QuerySet[Service]: + """ + Get services by team member. + + Args: + profile_id: The profile ID + + Returns: + QuerySet of services assigned to the team member + """ + return Service.objects.filter(team_members__id=profile_id) + + @classmethod + def get_by_status(cls, status: str) -> QuerySet[Service]: + """ + Get services by status. + + Args: + status: The service status + + Returns: + QuerySet of services with the specified status + """ + return Service.objects.filter(status=status) + + @classmethod + def get_by_date_range(cls, start_date: date = None, end_date: date = None) -> QuerySet[Service]: + """ + Get services within a date range. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + QuerySet of services within the date range + """ + return cls.filter_by_date_range(start_date, end_date) + + @classmethod + def get_upcoming(cls) -> QuerySet[Service]: + """ + Get upcoming services. + + Returns: + QuerySet of upcoming services + """ + return Service.objects.filter(date__gte=timezone.now().date()) + + @classmethod + def get_past_due(cls) -> QuerySet[Service]: + """ + Get past due services. + + Returns: + QuerySet of past due services + """ + return Service.objects.filter( + date__lt=timezone.now().date(), + status__in=['scheduled', 'in_progress'] + ) + + @classmethod + def get_for_today(cls) -> QuerySet[Service]: + """ + Get services scheduled for today. + + Returns: + QuerySet of services scheduled for today + """ + return Service.objects.filter(date=timezone.now().date()) + + @classmethod + def search(cls, search_term: str, search_fields: List[str] = None) -> QuerySet[Service]: + """ + Search services by account or notes. + + Args: + search_term: The search term + search_fields: Optional list of fields to search (ignored, using predefined fields) + + Returns: + QuerySet of matching services + """ + return super().search( + search_term, + ['account__name', 'notes'] + ) + + @classmethod + def filter_services( + cls, + account_id: str = None, + status: str = None, + date_from: date = None, + date_to: date = None, + team_member_id: str = None + ) -> QuerySet[Service]: + """ + Filter services by multiple criteria. + + Args: + account_id: Filter by account ID + status: Filter by status + date_from: Filter by start date (inclusive) + date_to: Filter by end date (inclusive) + team_member_id: Filter by team member ID + + Returns: + QuerySet of matching services + """ + queryset = Service.objects.all() + + if account_id: + queryset = queryset.filter(account_id=account_id) + + if status: + queryset = queryset.filter(status=status) + + if date_from: + queryset = queryset.filter(date__gte=date_from) + + if date_to: + queryset = queryset.filter(date__lte=date_to) + + if team_member_id: + queryset = queryset.filter(team_members__id=team_member_id) + + return queryset + + @classmethod + def create_with_team_members(cls, data: Dict[str, Any], team_member_ids: List[str]) -> Service: + """ + Create a service with team members. + + Args: + data: Service data + team_member_ids: List of team member IDs + + Returns: + The created service + """ + # Create the service + service = cls.create(data) + + # Add team members + if team_member_ids: + team_members = Profile.objects.filter(id__in=team_member_ids) + service.team_members.set(team_members) + + return service + + @classmethod + def update_status(cls, service_id: str, status: str) -> Optional[Service]: + """ + Update service status. + + Args: + service_id: The service ID + status: The new status + + Returns: + The updated service or None if not found + """ + service = cls.get_by_id(service_id) + if not service: + return None + + service.status = status + + # Set completed_at if status is 'completed' + if status == 'completed' and not service.completed_at: + service.completed_at = timezone.now() + + service.save() + return service + + @classmethod + def assign_team_members(cls, service_id: str, team_member_ids: List[str]) -> Optional[Service]: + """ + Assign team members to a service. + + Args: + service_id: The service ID + team_member_ids: List of team member IDs + + Returns: + The updated service or None if not found + """ + service = cls.get_by_id(service_id) + if not service: + return None + + team_members = Profile.objects.filter(id__in=team_member_ids) + service.team_members.set(team_members) + + return service \ No newline at end of file diff --git a/backend/core/services/__init__.py b/backend/core/services/__init__.py new file mode 100644 index 0000000..da52112 --- /dev/null +++ b/backend/core/services/__init__.py @@ -0,0 +1,16 @@ +""" +Service layer for business logic. +""" +from backend.core.services.auth import AuthService +from backend.core.services.billing import BillingService +from backend.core.services.scheduling import SchedulingService +from backend.core.services.reporting import ReportingService +from backend.core.services.notifications import NotificationService + +__all__ = [ + 'AuthService', + 'BillingService', + 'SchedulingService', + 'ReportingService', + 'NotificationService' +] \ No newline at end of file diff --git a/backend/core/services/auth.py b/backend/core/services/auth.py new file mode 100644 index 0000000..4033d71 --- /dev/null +++ b/backend/core/services/auth.py @@ -0,0 +1,98 @@ +""" +Authentication and authorization services. +""" +from typing import Optional, Dict, Any +from django.contrib.auth.models import User +from django.contrib.auth import authenticate +from django.utils import timezone +from backend.core.models import Profile +from backend.core.repositories.profiles.profiles import ProfileRepository + + +class AuthService: + """ + Service for authentication and authorization operations. + """ + + @staticmethod + def authenticate_user(username: str, password: str) -> Optional[User]: + """ + Authenticate a user with username and password. + + Args: + username: Username + password: Password + + Returns: + User object if authenticated, None otherwise + """ + return authenticate(username=username, password=password) + + @staticmethod + def get_user_profile(user_id: int) -> Optional[Profile]: + """ + Get a user's profile. + + Args: + user_id: User ID + + Returns: + Profile object or None if not found + """ + try: + user = User.objects.get(id=user_id) + return ProfileRepository.get_by_user(user) + except User.DoesNotExist: + return None + + @staticmethod + def check_permission(user: User, permission: str) -> bool: + """ + Check if a user has a specific permission. + + Args: + user: User object + permission: Permission string (e.g., 'core.add_customer') + + Returns: + True if user has permission, False otherwise + """ + return user.has_perm(permission) + + @staticmethod + def log_login(user: User) -> None: + """ + Log a user login. + + Args: + user: User object + """ + user.last_login = timezone.now() + user.save(update_fields=['last_login']) + + @staticmethod + def create_user(user_data: Dict[str, Any], profile_data: Dict[str, Any]) -> User: + """ + Create a new user with profile. + + Args: + user_data: User data (username, email, password, etc.) + profile_data: Profile data + + Returns: + Created User object + """ + # Create the user + user = User.objects.create_user( + username=user_data['username'], + email=user_data.get('email', ''), + password=user_data['password'], + first_name=user_data.get('first_name', ''), + last_name=user_data.get('last_name', '') + ) + + # Create the profile + profile_data['user'] = user + ProfileRepository.create(profile_data) + + return user \ No newline at end of file diff --git a/backend/core/services/billing.py b/backend/core/services/billing.py new file mode 100644 index 0000000..7cde0b8 --- /dev/null +++ b/backend/core/services/billing.py @@ -0,0 +1,145 @@ +""" +Billing and invoice services. +""" +from typing import List, Optional +from datetime import date, timedelta +from django.utils import timezone +from django.db import transaction +from backend.core.models import Account, Project, Invoice +from backend.core.repositories.customers.customers import CustomerRepository +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.projects.projects import ProjectRepository +from backend.core.repositories.invoices.invoices import InvoiceRepository +from backend.core.repositories.revenues.revenues import RevenueRepository + + +class BillingService: + """ + Service for billing and invoice operations. + """ + + @staticmethod + @transaction.atomic + def generate_invoice_for_customer( + customer_id: str, + invoice_date: date = None, + include_accounts: bool = True, + include_projects: bool = True, + account_ids: List[str] = None, + project_ids: List[str] = None + ) -> Optional[Invoice]: + """ + Generate an invoice for a customer. + + Args: + customer_id: Customer ID + invoice_date: Invoice date (defaults to today) + include_accounts: Whether to include all accounts + include_projects: Whether to include all completed projects + account_ids: Specific account IDs to include (if not including all) + project_ids: Specific project IDs to include (if not including all) + + Returns: + Generated invoice or None if no items to invoice + """ + # Get the customer + customer = CustomerRepository.get_by_id(customer_id) + if not customer: + return None + + # Use today's date if not specified + if not invoice_date: + invoice_date = timezone.now().date() + + # Get accounts to invoice + if include_accounts: + # Get all active accounts + accounts_to_invoice = AccountRepository.get_active_by_customer(customer_id) + elif account_ids: + # Get specific accounts + accounts_to_invoice = Account.objects.filter(id__in=account_ids) + else: + accounts_to_invoice = Account.objects.none() + + # Get projects to invoice + if include_projects: + # Get all completed projects without invoices + projects_to_invoice = ProjectRepository.get_without_invoice() + elif project_ids: + # Get specific projects + projects_to_invoice = Project.objects.filter(id__in=project_ids) + else: + projects_to_invoice = Project.objects.none() + + # Calculate total amount + total_amount = 0 + + # Add account revenue + for account in accounts_to_invoice: + active_revenue = RevenueRepository.get_active_by_account(account.id) + if active_revenue: + # For monthly billing, divide annual amount by 12 or use the full amount + total_amount += active_revenue.amount + + # Add project amounts + for project in projects_to_invoice: + total_amount += project.amount + + # Don't create an invoice if there's nothing to invoice + if total_amount <= 0: + return None + + # Create the invoice + invoice_data = { + 'customer': customer_id, + 'date': invoice_date, + 'status': 'draft', + 'total_amount': total_amount + } + + # Convert querysets to list of IDs + account_ids_list = [str(account.id) for account in accounts_to_invoice] + project_ids_list = [str(project.id) for project in projects_to_invoice] + + # Create the invoice with items + invoice = InvoiceRepository.create_with_items( + invoice_data, + account_ids_list, + project_ids_list + ) + + return invoice + + @staticmethod + def mark_overdue_invoices() -> int: + """ + Identify and mark overdue invoices. + + Returns: + Number of invoices marked as overdue + """ + thirty_days_ago = timezone.now().date() - timedelta(days=30) + + # Get sent invoices that are more than 30 days old + overdue_invoices = Invoice.objects.filter( + status='sent', + date__lt=thirty_days_ago + ) + + # Update them to overdue status + count = overdue_invoices.update(status='overdue') + + return count + + @staticmethod + def get_outstanding_balance(customer_id: str) -> float: + """ + Get outstanding balance for a customer. + + Args: + customer_id: Customer ID + + Returns: + Total outstanding amount + """ + return InvoiceRepository.get_total_outstanding(customer_id) \ No newline at end of file diff --git a/backend/core/services/notifications.py b/backend/core/services/notifications.py new file mode 100644 index 0000000..05d265c --- /dev/null +++ b/backend/core/services/notifications.py @@ -0,0 +1,157 @@ +""" +Notification services for emails, alerts, and reminders. +""" +from typing import Dict, Any +from datetime import timedelta +from django.utils import timezone +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.conf import settings +from backend.core.repositories.services.services import ServiceRepository +from backend.core.repositories.invoices.invoices import InvoiceRepository + + +class NotificationService: + """ + Service for sending notifications and alerts. + """ + + @staticmethod + def send_email( + recipient_email: str, + subject: str, + template_name: str, + context: Dict[str, Any] + ) -> bool: + """ + Send an email using a template. + + Args: + recipient_email: Recipient email address + subject: Email subject + template_name: Template name + context: Template context + + Returns: + True if email was sent successfully, False otherwise + """ + try: + html_content = render_to_string(f"emails/{template_name}.html", context) + text_content = render_to_string(f"emails/{template_name}.txt", context) + + send_mail( + subject=subject, + message=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[recipient_email], + html_message=html_content, + fail_silently=False + ) + + return True + except Exception as e: + # Log the error + print(f"Error sending email: {str(e)}") + return False + + @staticmethod + def send_service_reminders() -> int: + """ + Send service reminders for tomorrow's services. + + Returns: + Number of reminders sent + """ + tomorrow = timezone.now().date() + timedelta(days=1) + + # Get services scheduled for tomorrow + services = ServiceRepository.get_all(date=tomorrow) + + reminder_count = 0 + + for service in services: + # Get the account + account = service.account + + # Skip if no account (shouldn't happen) + if not account: + continue + + # Get team members + team_members = service.team_members.all() + + # Send reminder to each team member + for member in team_members: + # Skip if no email + if not member.email: + continue + + # Prepare context + context = { + 'service': service, + 'account': account, + 'team_member': member, + 'service_time': f"{service.deadline_start.strftime('%I:%M %p')} - {service.deadline_end.strftime('%I:%M %p')}" if service.deadline_start and service.deadline_end else "All day" + } + + # Send email + success = NotificationService.send_email( + recipient_email=member.email, + subject=f"Service Reminder: {account.name} - {tomorrow.strftime('%m/%d/%Y')}", + template_name="service_reminder", + context=context + ) + + if success: + reminder_count += 1 + + return reminder_count + + @staticmethod + def send_overdue_invoice_reminders() -> int: + """ + Send reminders for overdue invoices. + + Returns: + Number of reminders sent + """ + # Get overdue invoices + overdue_invoices = InvoiceRepository.get_overdue() + + reminder_count = 0 + + for invoice in overdue_invoices: + # Get the customer + customer = invoice.customer + + # Skip if no customer (shouldn't happen) + if not customer: + continue + + # Skip if no billing email + if not customer.billing_email: + continue + + # Calculate days overdue + days_overdue = (timezone.now().date() - invoice.date).days - 30 + + # Prepare context + context = { + 'invoice': invoice, + 'customer': customer, + 'days_overdue': days_overdue, + 'total_amount': invoice.total_amount + } + + # Send email + success = NotificationService.send_email( + recipient_email=customer.billing_email, + subject=f"Overdue Invoice Reminder: {invoice.id}", + template_name="overdue_invoice_reminder", + context=context + ) + + if success: + reminder_count += 1 + + return reminder_count \ No newline at end of file diff --git a/backend/core/services/reporting.py b/backend/core/services/reporting.py new file mode 100644 index 0000000..4479566 --- /dev/null +++ b/backend/core/services/reporting.py @@ -0,0 +1,160 @@ +""" +Reporting services for analytics and business intelligence. +""" +from typing import Dict, Any, List +from datetime import date, timedelta +from django.utils import timezone +from backend.core.repositories.services.services import ServiceRepository +from backend.core.repositories.projects.projects import ProjectRepository +from backend.core.repositories.invoices.invoices import InvoiceRepository +from backend.core.repositories.reports.reports import ReportRepository +from backend.core.repositories.revenues.revenues import RevenueRepository +from backend.core.repositories.labor.labor import LaborRepository + + +class ReportingService: + """ + Service for generating business reports and analytics. + """ + + @staticmethod + def get_monthly_summary(year: int, month: int) -> Dict[str, Any]: + """ + Get monthly business summary. + + Args: + year: Year + month: Month (1-12) + + Returns: + Dictionary with monthly summary data + """ + # Calculate date range for the month + start_date = date(year, month, 1) + if month == 12: + end_date = date(year + 1, 1, 1) - timedelta(days=1) + else: + end_date = date(year, month + 1, 1) - timedelta(days=1) + + # Get services in the month + services = ServiceRepository.get_by_date_range(start_date, end_date) + + # Get projects in the month + projects = ProjectRepository.get_by_date_range(start_date, end_date) + + # Get invoices in the month + invoices = InvoiceRepository.get_by_date_range(start_date, end_date) + + # Get paid invoices in the month + paid_invoices = invoices.filter(status='paid') + + # Calculate total revenue and labor for the month + total_revenue = RevenueRepository.get_total_revenue(None, start_date, end_date) + total_labor = LaborRepository.get_total_labor_cost(None, start_date, end_date) + + # Calculate profit + profit = total_revenue - total_labor + + # Service statistics + service_stats = { + 'total': services.count(), + 'completed': services.filter(status='completed').count(), + 'cancelled': services.filter(status='cancelled').count(), + 'completion_rate': services.filter( + status='completed').count() / services.count() if services.count() > 0 else 0 + } + + # Project statistics + project_stats = { + 'total': projects.count(), + 'completed': projects.filter(status='completed').count(), + 'cancelled': projects.filter(status='cancelled').count(), + 'completion_rate': projects.filter( + status='completed').count() / projects.count() if projects.count() > 0 else 0, + 'total_amount': sum(p.amount for p in projects), + 'total_labor': sum(p.labor for p in projects), + 'profit': sum(p.amount - p.labor for p in projects) + } + + # Invoice statistics + invoice_stats = { + 'total': invoices.count(), + 'paid': paid_invoices.count(), + 'payment_rate': paid_invoices.count() / invoices.count() if invoices.count() > 0 else 0, + 'total_amount': sum(i.total_amount for i in invoices), + 'paid_amount': sum(i.total_amount for i in paid_invoices) + } + + return { + 'period': { + 'year': year, + 'month': month, + 'start_date': start_date.isoformat(), + 'end_date': end_date.isoformat() + }, + 'financials': { + 'revenue': total_revenue, + 'labor': total_labor, + 'profit': profit, + 'profit_margin': (profit / total_revenue) * 100 if total_revenue > 0 else 0 + }, + 'services': service_stats, + 'projects': project_stats, + 'invoices': invoice_stats + } + + @staticmethod + def get_team_performance(start_date: date = None, end_date: date = None) -> List[Dict[str, Any]]: + """ + Get team member performance metrics. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + List of team member performance data + """ + # Set default date range to current month if not provided + if not start_date: + today = timezone.now().date() + start_date = date(today.year, today.month, 1) + + if not end_date: + end_date = timezone.now().date() + + # Get team members with activity + team_activity = ReportRepository.get_team_summary(start_date, end_date) + + team_performance = [] + for member_id, summary in team_activity.get('member_summaries', {}).items(): + # Get services completed by the team member + services_completed = ServiceRepository.get_by_team_member(member_id).filter( + status='completed', + date__gte=start_date, + date__lte=end_date + ).count() + + # Get projects completed by the team member + projects_completed = ProjectRepository.get_by_team_member(member_id).filter( + status='completed', + date__gte=start_date, + date__lte=end_date + ).count() + + # Calculate metrics + performance = { + 'member_id': member_id, + 'name': summary['name'], + 'reports_submitted': summary['report_count'], + 'services_completed': services_completed, + 'projects_completed': projects_completed, + 'total_work_items': services_completed + projects_completed + } + + team_performance.append(performance) + + # Sort by total work items descending + team_performance.sort(key=lambda x: x['total_work_items'], reverse=True) + + return team_performance \ No newline at end of file diff --git a/backend/core/services/scheduling.py b/backend/core/services/scheduling.py new file mode 100644 index 0000000..5397100 --- /dev/null +++ b/backend/core/services/scheduling.py @@ -0,0 +1,123 @@ +""" +Scheduling services for services and projects. +""" +from typing import List, Dict, Any +from datetime import date +from django.utils import timezone +from django.db import transaction +from backend.core.models import Service +from backend.core.repositories.accounts.accounts import AccountRepository +from backend.core.repositories.schedules.schedules import ScheduleRepository +from backend.core.repositories.services.services import ServiceRepository + + +class SchedulingService: + """ + Service for scheduling operations. + """ + + @staticmethod + @transaction.atomic + def generate_services_from_schedule( + schedule_id: str, + start_date: date, + end_date: date + ) -> List[Service]: + """ + Generate services based on a schedule for a date range. + + Args: + schedule_id: Schedule ID + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + List of generated services + """ + return ScheduleRepository.generate_services(schedule_id, start_date, end_date) + + @staticmethod + @transaction.atomic + def generate_services_for_all_accounts( + start_date: date, + end_date: date + ) -> Dict[str, List[Service]]: + """ + Generate services for all accounts with active schedules.py. + + Args: + start_date: Start date (inclusive) + end_date: End date (inclusive) + + Returns: + Dictionary mapping account IDs to lists of generated services + """ + # Get all active schedules.py + active_schedules = ScheduleRepository.get_active() + + # Generate services for each schedule + result = {} + + for schedule in active_schedules: + services = ScheduleRepository.generate_services( + str(schedule.id), # Convert UUID to string if needed + start_date, + end_date + ) + + # Access account.id instead of account_id + account_id = str(schedule.account.id) + result[account_id] = services + + return result + + @staticmethod + def get_daily_service_schedule(date_str: str = None) -> List[Dict[str, Any]]: + """ + Get schedule of services for a specific day. + + Args: + date_str: Date string (YYYY-MM-DD) or today if None + + Returns: + List of services with account and team member info + """ + # Parse date or use today + if date_str: + try: + target_date = date.fromisoformat(date_str) + except ValueError: + # Invalid date format, use today + target_date = timezone.now().date() + else: + target_date = timezone.now().date() + + # Get services for the day + services = ServiceRepository.get_all(date=target_date) + + # Format the result + result = [] + for service in services: + # Access account.id instead of account_id + account = AccountRepository.get_by_id(str(service.account.id)) + + service_data = { + 'id': str(service.id), + 'account_name': account.name if account else 'Unknown', + 'account_address': f"{account.street_address}, {account.city}, {account.state}" if account else 'Unknown', + 'status': service.status, + 'deadline_start': service.deadline_start.strftime('%H:%M') if service.deadline_start else None, + 'deadline_end': service.deadline_end.strftime('%H:%M') if service.deadline_end else None, + 'team_members': [ + { + 'id': str(tm.id), + 'name': f"{tm.first_name} {tm.last_name}", + 'role': tm.role + } + for tm in service.team_members.all() + ] + } + + result.append(service_data) + + return result \ No newline at end of file diff --git a/backend/core/utils/__init__.py b/backend/core/utils/__init__.py new file mode 100644 index 0000000..bd6bebb --- /dev/null +++ b/backend/core/utils/__init__.py @@ -0,0 +1,12 @@ +from backend.core.utils.validators import is_valid_uuid, is_valid_email, validate_decimal_amount +from backend.core.utils.helpers import generate_uuid, format_date, format_datetime, parse_date + +__all__ = [ + 'is_valid_uuid', + 'is_valid_email', + 'validate_decimal_amount', + 'generate_uuid', + 'format_date', + 'format_datetime', + 'parse_date', +] \ No newline at end of file diff --git a/backend/core/utils/helpers.py b/backend/core/utils/helpers.py new file mode 100644 index 0000000..571f586 --- /dev/null +++ b/backend/core/utils/helpers.py @@ -0,0 +1,327 @@ +""" +Helper functions for the API. +Provides utility functions for data transformation, processing, and other common tasks. +""" +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional +import json + + +def generate_uuid() -> str: + """ + Generate a new UUID string. + + Returns: + str: A new UUID string. + """ + return str(uuid.uuid4()) + + +def format_date(date_obj: datetime) -> str: + """ + Format a datetime object as a date string (YYYY-MM-DD). + + Args: + date_obj: The datetime object to format. + + Returns: + str: The formatted date string. + """ + return date_obj.strftime('%Y-%m-%d') + + +def format_datetime(datetime_obj: datetime) -> str: + """ + Format a datetime object as an ISO string. + + Args: + datetime_obj: The datetime object to format. + + Returns: + str: The formatted datetime string. + """ + return datetime_obj.isoformat() + + +def parse_date(date_str: str) -> Optional[datetime]: + """ + Parse a date string into a datetime object. + + Args: + date_str: The date string to parse (YYYY-MM-DD). + + Returns: + Optional[datetime]: The parsed datetime or None if invalid. + """ + if not date_str: + return None + + try: + return datetime.fromisoformat(date_str) + except ValueError: + return None + + +def parse_datetime(datetime_str: str) -> Optional[datetime]: + """ + Parse a datetime string into a datetime object. + + Args: + datetime_str: The datetime string to parse. + + Returns: + Optional[datetime]: The parsed datetime or None if invalid. + """ + if not datetime_str: + return None + + try: + return datetime.fromisoformat(datetime_str) + except ValueError: + return None + + +def to_camel_case(snake_str: str) -> str: + """ + Convert a snake_case string to camelCase. + + Args: + snake_str: The snake_case string to convert. + + Returns: + str: The camelCase string. + """ + components = snake_str.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + +def to_snake_case(camel_str: str) -> str: + """ + Convert a camelCase string to snake_case. + + Args: + camel_str: The camelCase string to convert. + + Returns: + str: The snake_case string. + """ + import re + # Use regex to find all capital letters and insert underscore before them + s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() + + +def convert_keys_to_camel_case(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert all dictionary keys from snake_case to camelCase. + + Args: + data: The dictionary with snake_case keys. + + Returns: + Dict[str, Any]: A new dictionary with camelCase keys. + """ + if not isinstance(data, dict): + return data + + result = {} + for key, value in data.items(): + if isinstance(value, dict): + value = convert_keys_to_camel_case(value) + elif isinstance(value, list): + value = [ + convert_keys_to_camel_case(item) if isinstance(item, dict) else item + for item in value + ] + result[to_camel_case(key)] = value + return result + + +def convert_keys_to_snake_case(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert all dictionary keys from camelCase to snake_case. + + Args: + data: The dictionary with camelCase keys. + + Returns: + Dict[str, Any]: A new dictionary with snake_case keys. + """ + if not isinstance(data, dict): + return data + + result = {} + for key, value in data.items(): + if isinstance(value, dict): + value = convert_keys_to_snake_case(value) + elif isinstance(value, list): + value = [ + convert_keys_to_snake_case(item) if isinstance(item, dict) else item + for item in value + ] + result[to_snake_case(key)] = value + return result + + +def filter_none_values(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Remove all None values from a dictionary. + + Args: + data: The dictionary to filter. + + Returns: + Dict[str, Any]: A new dictionary without None values. + """ + return {k: v for k, v in data.items() if v is not None} + + +def get_week_start_end(date: datetime) -> tuple: + """ + Get the start and end dates of the week containing the given date. + + Args: + date: The date to get the week for. + + Returns: + tuple: (start_date, end_date) of the week. + """ + # Monday is 0 and Sunday is 6 + start = date - timedelta(days=date.weekday()) + end = start + timedelta(days=6) + return start, end + + +def get_month_start_end(date: datetime) -> tuple: + """ + Get the start and end dates of the month containing the given date. + + Args: + date: The date to get the month for. + + Returns: + tuple: (start_date, end_date) of the month. + """ + start = date.replace(day=1) + # Get the last day by going to next month and subtracting one day + if date.month == 12: + end = datetime(date.year + 1, 1, 1) - timedelta(days=1) + else: + end = datetime(date.year, date.month + 1, 1) - timedelta(days=1) + + return start, end + + +def get_date_range(start_date: str, end_date: str) -> List[str]: + """ + Get a list of date strings between start_date and end_date (inclusive). + + Args: + start_date: The start date string (YYYY-MM-DD). + end_date: The end date string (YYYY-MM-DD). + + Returns: + List[str]: List of date strings in the range. + """ + start = parse_date(start_date) + end = parse_date(end_date) + + if not start or not end: + return [] + + if start > end: + return [] + + date_list = [] + current = start + + while current <= end: + date_list.append(format_date(current)) + current += timedelta(days=1) + + return date_list + + +def date_diff_in_days(start_date: str, end_date: str) -> int: + """ + Calculate the difference in days between two date strings. + + Args: + start_date: The start date string (YYYY-MM-DD). + end_date: The end date string (YYYY-MM-DD). + + Returns: + int: The number of days between the dates. Returns 0 if dates are invalid. + """ + start = parse_date(start_date) + end = parse_date(end_date) + + if not start or not end: + return 0 + + return (end - start).days + + +def dict_to_json(data: Dict[str, Any]) -> str: + """ + Convert a dictionary to a JSON string. + + Args: + data: The dictionary to convert. + + Returns: + str: The JSON string. + """ + return json.dumps(data, default=str) + + +def json_to_dict(json_str: str) -> Dict[str, Any]: + """ + Convert a JSON string to a dictionary. + + Args: + json_str: The JSON string to convert. + + Returns: + Dict[str, Any]: The dictionary. Returns empty dict if JSON is invalid. + """ + try: + return json.loads(json_str) + except (json.JSONDecodeError, TypeError): + return {} + + +def paginate_results(items: List[Any], page: int = 1, page_size: int = 10) -> Dict[str, Any]: + """ + Paginate a list of items. + + Args: + items: The list of items to paginate. + page: The page number (1-based). + page_size: The number of items per page. + + Returns: + Dict[str, Any]: A dictionary with pagination info and results. + """ + if page < 1: + page = 1 + + if page_size < 1: + page_size = 10 + + total_items = len(items) + total_pages = (total_items + page_size - 1) // page_size + + start_idx = (page - 1) * page_size + end_idx = min(start_idx + page_size, total_items) + + return { + 'page': page, + 'page_size': page_size, + 'total_items': total_items, + 'total_pages': total_pages, + 'has_previous': page > 1, + 'has_next': page < total_pages, + 'items': items[start_idx:end_idx] + } \ No newline at end of file diff --git a/backend/core/utils/validators.py b/backend/core/utils/validators.py new file mode 100644 index 0000000..aaaf53d --- /dev/null +++ b/backend/core/utils/validators.py @@ -0,0 +1,199 @@ +""" +Validators for API data validation. +Provides reusable validation functions for domain models data. +""" +import re +import uuid +from datetime import datetime, date +from typing import Any, Dict, List, Optional, Union + + +def is_valid_uuid(value: Any) -> bool: + """ + Check if a value is a valid UUID. + + Args: + value: The value to check. + + Returns: + bool: True if the value is a valid UUID, False otherwise. + """ + if isinstance(value, uuid.UUID): + return True + + if not isinstance(value, str): + return False + + try: + uuid.UUID(value) + return True + except (ValueError, AttributeError, TypeError): + return False + + +def is_valid_email(email: str) -> bool: + """ + Validate an email address format. + + Args: + email: The email address to validate. + + Returns: + bool: True if the email format is valid, False otherwise. + """ + if not email or not isinstance(email, str): + return False + + # Simple regex for email validation + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(email_pattern, email)) + + +def is_valid_phone(phone: str) -> bool: + """ + Validate a phone number format. + + Args: + phone: The phone number to validate. + + Returns: + bool: True if the phone format is valid, False otherwise. + """ + if not phone or not isinstance(phone, str): + return False + + # Remove common formatting characters + cleaned = re.sub(r'[\s\-\(\)\.]+', '', phone) + + # Check if it's a valid format (allows international format) + phone_pattern = r'^(\+\d{1,3})?(\d{10,15})$' + return bool(re.match(phone_pattern, cleaned)) + + +def is_valid_date(date_val: Any) -> bool: + """ + Validate a date string in ISO format (YYYY-MM-DD). + + Args: + date_val: The date to validate. + + Returns: + bool: True if the date is valid, False otherwise. + """ + return isinstance(date_val, date) + + +def is_valid_datetime(datetime_str: str) -> bool: + """ + Validate a datetime string in ISO format. + + Args: + datetime_str: The datetime string to validate. + + Returns: + bool: True if the datetime format is valid, False otherwise. + """ + if not datetime_str or not isinstance(datetime_str, str): + return False + + try: + datetime.fromisoformat(datetime_str) + return True + except ValueError: + return False + + +def validate_required_fields(data: Dict[str, Any], required_fields: List[str]) -> List[str]: + """ + Validate that all required fields are present and not empty in the data. + + Args: + data: Dictionary containing the data to validate. + required_fields: List of field names that are required. + + Returns: + List[str]: List of missing field names. Empty if all required fields are present. + """ + missing_fields = [] + + for field in required_fields: + if field not in data or data[field] is None or (isinstance(data[field], str) and not data[field].strip()): + missing_fields.append(field) + + return missing_fields + + +def validate_model_exists(model_id: str, model_type: str, + repository_method: callable, error_message: Optional[str] = None) -> Dict[str, Any]: + """ + Validate that a model with the given ID exists. + + Args: + model_id: The ID of the model to check. + model_type: The type of model (e.g., 'customer', 'account'). + repository_method: Repository method to fetch the model. + error_message: Custom error message. If None, a default message is used. + + Returns: + Dict[str, Any]: Dictionary with 'valid' and 'error' keys. + """ + if not is_valid_uuid(model_id): + return { + 'valid': False, + 'error': f"Invalid {model_type} ID format." + } + + model = repository_method(model_id) + if not model: + return { + 'valid': False, + 'error': error_message or f"{model_type.capitalize()} with ID {model_id} not found." + } + + return { + 'valid': True, + 'model': model, + 'error': None + } + + +def validate_decimal_amount(amount: Union[float, str, int], field_name: str = 'amount') -> Dict[str, Any]: + """ + Validate a decimal amount (e.g., for money). + + Args: + amount: The amount to validate. + field_name: The name of the field being validated. + + Returns: + Dict[str, Any]: Dictionary with 'valid' and 'error' keys. + """ + try: + # Convert to float if it's a string + if isinstance(amount, str): + amount = float(amount) + + # Check if it's a number + if not isinstance(amount, (int, float)): + return { + 'valid': False, + 'error': f"{field_name} must be a number." + } + + # Check if it's non-negative + if amount < 0: + return { + 'valid': False, + 'error': f"{field_name} cannot be negative." + } + + return { + 'valid': True, + 'amount': float(amount), + 'error': None + } + except ValueError: + return { + 'valid': False, + 'error': f"Invalid {field_name} format." + } \ No newline at end of file diff --git a/backend/graphql_api/__init__.py b/backend/graphql_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/apps.py b/backend/graphql_api/apps.py new file mode 100644 index 0000000..d6d8569 --- /dev/null +++ b/backend/graphql_api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GraphqlConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'backend.graphql_api' diff --git a/backend/graphql_api/inputs/__init__.py b/backend/graphql_api/inputs/__init__.py new file mode 100644 index 0000000..6cc19d5 --- /dev/null +++ b/backend/graphql_api/inputs/__init__.py @@ -0,0 +1,91 @@ +from backend.graphql_api.inputs.accounts.accounts import ( + AccountCreateInput, + AccountUpdateInput, + AccountFilterInput +) +from backend.graphql_api.inputs.customers.customers import ( + CustomerCreateInput, + CustomerUpdateInput, + CustomerFilterInput +) +from backend.graphql_api.inputs.services.services import ( + ServiceCreateInput, + ServiceUpdateInput, + ServiceFilterInput +) +from backend.graphql_api.inputs.projects.projects import ( + ProjectCreateInput, + ProjectUpdateInput, + ProjectFilterInput +) +from backend.graphql_api.inputs.invoices.invoices import ( + InvoiceCreateInput, + InvoiceUpdateInput, + InvoiceFilterInput +) +from backend.graphql_api.inputs.labor.labor import ( + LaborCreateInput, + LaborUpdateInput, + LaborFilterInput +) +from backend.graphql_api.inputs.profiles.profiles import ( + ProfileCreateInput, + ProfileUpdateInput, + ProfileSearchInput +) +from backend.graphql_api.inputs.reports.reports import ( + ReportCreateInput, + ReportUpdateInput, + ReportFilterInput +) +from backend.graphql_api.inputs.revenues.revenues import ( + RevenueCreateInput, + RevenueUpdateInput, + RevenueFilterInput +) +from backend.graphql_api.inputs.schedules.schedules import ( + ScheduleCreateInput, + ScheduleUpdateInput, + ScheduleFilterInput +) +from backend.graphql_api.inputs.punchlists.punchlists import ( + PunchlistCreateInput, + PunchlistUpdateInput, + PunchlistFilterInput +) + +__all__ = [ + 'AccountCreateInput', + 'AccountUpdateInput', + 'AccountFilterInput', + 'CustomerCreateInput', + 'CustomerUpdateInput', + 'CustomerFilterInput', + 'ServiceCreateInput', + 'ServiceUpdateInput', + 'ServiceFilterInput', + 'ProjectCreateInput', + 'ProjectUpdateInput', + 'ProjectFilterInput', + 'InvoiceCreateInput', + 'InvoiceUpdateInput', + 'InvoiceFilterInput', + 'LaborCreateInput', + 'LaborUpdateInput', + 'LaborFilterInput', + 'ProfileCreateInput', + 'ProfileUpdateInput', + 'ProfileSearchInput', + 'ReportCreateInput', + 'ReportUpdateInput', + 'ReportFilterInput', + 'RevenueCreateInput', + 'RevenueUpdateInput', + 'RevenueFilterInput', + 'ScheduleCreateInput', + 'ScheduleUpdateInput', + 'ScheduleFilterInput', + 'PunchlistCreateInput', + 'PunchlistUpdateInput', + 'PunchlistFilterInput' +] diff --git a/backend/graphql_api/inputs/accounts/__init__.py b/backend/graphql_api/inputs/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/accounts/accounts.py b/backend/graphql_api/inputs/accounts/accounts.py new file mode 100644 index 0000000..aa6bb81 --- /dev/null +++ b/backend/graphql_api/inputs/accounts/accounts.py @@ -0,0 +1,79 @@ +import graphene +from graphene import InputObjectType + + +class AccountCreateInput(InputObjectType): + """ + Input type for creating a new account. + """ + customer_id = graphene.ID(required=True, description="ID of the customer this account belongs to") + name = graphene.String(required=True, description="Name of the account") + + # Address fields + street_address = graphene.String(required=True, description="Street address of the account") + city = graphene.String(required=True, description="City of the account") + state = graphene.String(required=True, description="State of the account") + zip_code = graphene.String(required=True, description="ZIP code of the account") + + # Primary contact fields + primary_contact_first_name = graphene.String(required=True, description="First name of primary contact") + primary_contact_last_name = graphene.String(required=True, description="Last name of primary contact") + primary_contact_phone = graphene.String(required=True, description="Phone number of primary contact") + primary_contact_email = graphene.String(required=True, description="Email of primary contact") + + # Secondary contact fields (optional) + secondary_contact_first_name = graphene.String(description="First name of secondary contact") + secondary_contact_last_name = graphene.String(description="Last name of secondary contact") + secondary_contact_phone = graphene.String(description="Phone number of secondary contact") + secondary_contact_email = graphene.String(description="Email of secondary contact") + + # Date fields + start_date = graphene.DateTime(required=True, description="Start date of the account (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the account (YYYY-MM-DD)") + + +class AccountUpdateInput(InputObjectType): + """ + Input type for updating an existing account. + """ + id = graphene.ID(required=True, description="ID of the account to update") + name = graphene.String(description="Name of the account") + + # Address fields + street_address = graphene.String(description="Street address of the account") + city = graphene.String(description="City of the account") + state = graphene.String(description="State of the account") + zip_code = graphene.String(description="ZIP code of the account") + + # Primary contact fields + primary_contact_first_name = graphene.String(description="First name of primary contact") + primary_contact_last_name = graphene.String(description="Last name of primary contact") + primary_contact_phone = graphene.String(description="Phone number of primary contact") + primary_contact_email = graphene.String(description="Email of primary contact") + + # Secondary contact fields + secondary_contact_first_name = graphene.String(description="First name of secondary contact") + secondary_contact_last_name = graphene.String(description="Last name of secondary contact") + secondary_contact_phone = graphene.String(description="Phone number of secondary contact") + secondary_contact_email = graphene.String(description="Email of secondary contact") + + # Date fields + start_date = graphene.DateTime(description="Start date of the account (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the account (YYYY-MM-DD)") + + +class AccountFilterInput(InputObjectType): + """ + Input type for filtering accounts. + """ + customer_id = graphene.ID(description="Filter by customer ID") + name = graphene.String(description="Filter by account name (partial match)") + city = graphene.String(description="Filter by city") + state = graphene.String(description="Filter by state") + zip_code = graphene.String(description="Filter by ZIP code") + is_active = graphene.Boolean(description="Filter by active status") + has_services = graphene.Boolean(description="Filter by whether the account has services") + has_projects = graphene.Boolean(description="Filter by whether the account has projects") + start_date_after = graphene.String(description="Filter by start date after (YYYY-MM-DD)") + start_date_before = graphene.String(description="Filter by start date before (YYYY-MM-DD)") + contact_email = graphene.String(description="Filter by contact email (partial match)") diff --git a/backend/graphql_api/inputs/customers/__init__.py b/backend/graphql_api/inputs/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/customers/customers.py b/backend/graphql_api/inputs/customers/customers.py new file mode 100644 index 0000000..1b93974 --- /dev/null +++ b/backend/graphql_api/inputs/customers/customers.py @@ -0,0 +1,84 @@ +import graphene +from graphene import InputObjectType + + +class CustomerCreateInput(InputObjectType): + """ + Input type for creating a new customer. + """ + name = graphene.String(required=True, description="Name of the customer") + + # Primary contact fields + primary_contact_first_name = graphene.String(required=True, description="First name of primary contact") + primary_contact_last_name = graphene.String(required=True, description="Last name of primary contact") + primary_contact_phone = graphene.String(required=True, description="Phone number of primary contact") + primary_contact_email = graphene.String(required=True, description="Email of primary contact") + + # Secondary contact fields (optional) + secondary_contact_first_name = graphene.String(description="First name of secondary contact") + secondary_contact_last_name = graphene.String(description="Last name of secondary contact") + secondary_contact_phone = graphene.String(description="Phone number of secondary contact") + secondary_contact_email = graphene.String(description="Email of secondary contact") + + # Billing information + billing_contact_first_name = graphene.String(required=True, description="First name of billing contact") + billing_contact_last_name = graphene.String(required=True, description="Last name of billing contact") + billing_street_address = graphene.String(required=True, description="Billing street address") + billing_city = graphene.String(required=True, description="Billing city") + billing_state = graphene.String(required=True, description="Billing state") + billing_zip_code = graphene.String(required=True, description="Billing ZIP code") + billing_email = graphene.String(required=True, description="Billing email") + billing_terms = graphene.String(required=True, description="Billing terms") + + # Date fields + start_date = graphene.DateTime(required=True, description="Start date of the customer relationship (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the customer relationship (YYYY-MM-DD)") + + +class CustomerUpdateInput(InputObjectType): + """ + Input type for updating an existing customer. + """ + name = graphene.String(description="Name of the customer") + + # Primary contact fields + primary_contact_first_name = graphene.String(description="First name of primary contact") + primary_contact_last_name = graphene.String(description="Last name of primary contact") + primary_contact_phone = graphene.String(description="Phone number of primary contact") + primary_contact_email = graphene.String(description="Email of primary contact") + + # Secondary contact fields + secondary_contact_first_name = graphene.String(description="First name of secondary contact") + secondary_contact_last_name = graphene.String(description="Last name of secondary contact") + secondary_contact_phone = graphene.String(description="Phone number of secondary contact") + secondary_contact_email = graphene.String(description="Email of secondary contact") + + # Billing information + billing_contact_first_name = graphene.String(description="First name of billing contact") + billing_contact_last_name = graphene.String(description="Last name of billing contact") + billing_street_address = graphene.String(description="Billing street address") + billing_city = graphene.String(description="Billing city") + billing_state = graphene.String(description="Billing state") + billing_zip_code = graphene.String(description="Billing ZIP code") + billing_email = graphene.String(description="Billing email") + billing_terms = graphene.String(description="Billing terms") + + # Date fields + start_date = graphene.DateTime(description="Start date of the customer relationship (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the customer relationship (YYYY-MM-DD)") + +class CustomerFilterInput(InputObjectType): + """ + Input type for filtering customers. + """ + id = graphene.ID(description="Filter by customer ID") + name = graphene.String(description="Filter by customer name (partial match)") + is_active = graphene.Boolean(description="Filter by active status") + has_accounts = graphene.Boolean(description="Filter by whether the customer has accounts") + start_date_after = graphene.String(description="Filter by start date after (YYYY-MM-DD)") + start_date_before = graphene.String(description="Filter by start date before (YYYY-MM-DD)") + billing_city = graphene.String(description="Filter by billing city") + billing_state = graphene.String(description="Filter by billing state") + billing_zip_code = graphene.String(description="Filter by billing ZIP code") + primary_contact_email = graphene.String(description="Filter by primary contact email (partial match)") + billing_email = graphene.String(description="Filter by billing email (partial match)") diff --git a/backend/graphql_api/inputs/invoices/__init__.py b/backend/graphql_api/inputs/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/invoices/invoices.py b/backend/graphql_api/inputs/invoices/invoices.py new file mode 100644 index 0000000..681be47 --- /dev/null +++ b/backend/graphql_api/inputs/invoices/invoices.py @@ -0,0 +1,57 @@ +import graphene +from graphene import InputObjectType + + +class InvoiceCreateInput(InputObjectType): + """ + Input type for creating a new invoice. + """ + customer_id = graphene.ID(required=True, description="ID of the customer for this invoice") + date = graphene.DateTime(required=True, description="Date of the invoice (YYYY-MM-DD)") + + # Related items + account_ids = graphene.List(graphene.ID, description="List of account IDs to include in this invoice") + project_ids = graphene.List(graphene.ID, description="List of project IDs to include in this invoice") + + # Status and payment + status = graphene.String(description="Status of the invoice (draft, sent, paid, overdue, cancelled)") + date_paid = graphene.DateTime(description="Date the invoice was paid (YYYY-MM-DD)") + payment_type = graphene.String(description="Payment type (check, credit_card, bank_transfer, cash)") + + # Financial + total_amount = graphene.Float(required=True, description="Total amount of the invoice") + + +class InvoiceUpdateInput(InputObjectType): + """ + Input type for updating an existing invoice. + """ + id = graphene.ID(required=True, description="ID of the invoice to update") + customer_id = graphene.ID(description="ID of the customer for this invoice") + date = graphene.DateTime(description="Date of the invoice (YYYY-MM-DD)") + + # Related items + account_ids = graphene.List(graphene.ID, description="List of account IDs to include in this invoice") + project_ids = graphene.List(graphene.ID, description="List of project IDs to include in this invoice") + + # Status and payment + status = graphene.String(description="Status of the invoice (draft, sent, paid, overdue, cancelled)") + date_paid = graphene.DateTime(description="Date the invoice was paid (YYYY-MM-DD)") + payment_type = graphene.String(description="Payment type (check, credit_card, bank_transfer, cash)") + + # Financial + total_amount = graphene.Float(description="Total amount of the invoice") + + +class InvoiceFilterInput(InputObjectType): + """ + Input type for filtering invoices. + """ + customer_id = graphene.ID(description="Filter by customer ID") + status = graphene.String(description="Filter by status") + start_date = graphene.DateTime(description="Filter by start date (inclusive)") + end_date = graphene.DateTime(description="Filter by end date (inclusive)") + min_amount = graphene.Float(description="Filter by minimum amount") + max_amount = graphene.Float(description="Filter by maximum amount") + is_paid = graphene.Boolean(description="Filter by paid status") + is_overdue = graphene.Boolean(description="Filter by overdue status") diff --git a/backend/graphql_api/inputs/labor/__init__.py b/backend/graphql_api/inputs/labor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/labor/labor.py b/backend/graphql_api/inputs/labor/labor.py new file mode 100644 index 0000000..33c4bc8 --- /dev/null +++ b/backend/graphql_api/inputs/labor/labor.py @@ -0,0 +1,48 @@ +import graphene +from graphene import InputObjectType + +class LaborCreateInput(InputObjectType): + """ + Input type for creating a new labor record. + """ + account_id = graphene.ID(required=True, description="ID of the account this labor record belongs to") + amount = graphene.String(required=True, description="Labor cost amount") + start_date = graphene.DateTime(required=True, description="Start date of the labor period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the labor period (YYYY-MM-DD)") + + +class LaborUpdateInput(InputObjectType): + """ + Input type for updating an existing labor record. + """ + id = graphene.ID(required=True, description="ID of the labor record to update") + account_id = graphene.ID(description="ID of the account this labor record belongs to") + amount = graphene.String(description="Labor cost amount") + start_date = graphene.DateTime(description="Start date of the labor period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the labor period (YYYY-MM-DD)") + + +class LaborEndInput(InputObjectType): + """ + Input type for ending a labor period. + """ + id = graphene.ID(required=True, description="ID of the labor record to end") + end_date = graphene.DateTime(description="End date of the labor period (YYYY-MM-DD), defaults to today if not provided") + + +class LaborCalculateInput(InputObjectType): + """ + Input type for calculating labor costs. + """ + account_id = graphene.ID(description="Calculate labor for a specific account") + start_date = graphene.String(description="Start date for calculation period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date for calculation period (YYYY-MM-DD)") + +class LaborFilterInput(graphene.InputObjectType): + """Input type for filtering labor records""" + account_id = graphene.ID(description="Filter by account ID") + is_active = graphene.Boolean(description="Filter by active status") + start_date = graphene.DateTime(description="Filter by start date") + end_date = graphene.DateTime(description="Filter by end date") + amount_min = graphene.Float(description="Filter by minimum amount") + amount_max = graphene.Float(description="Filter by maximum amount") \ No newline at end of file diff --git a/backend/graphql_api/inputs/profiles/__init__.py b/backend/graphql_api/inputs/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/profiles/profiles.py b/backend/graphql_api/inputs/profiles/profiles.py new file mode 100644 index 0000000..56f6ff9 --- /dev/null +++ b/backend/graphql_api/inputs/profiles/profiles.py @@ -0,0 +1,36 @@ +import graphene +from graphene import InputObjectType + +class ProfileCreateInput(InputObjectType): + """ + Input type for creating a new profile. + """ + user_id = graphene.ID(required=True, description="ID of the associated Django User") + first_name = graphene.String(required=True, description="First name of the user") + last_name = graphene.String(required=True, description="Last name of the user") + primary_phone = graphene.String(required=True, description="Primary phone number") + secondary_phone = graphene.String(description="Secondary phone number") + email = graphene.String(required=True, description="Email address") + role = graphene.String(required=True, description="Role (admin, team_leader, team_member)") + + +class ProfileUpdateInput(InputObjectType): + """ + Input type for updating an existing profile. + """ + id = graphene.ID(required=True, description="ID of the profile to update") + first_name = graphene.String(description="First name of the user") + last_name = graphene.String(description="Last name of the user") + primary_phone = graphene.String(description="Primary phone number") + secondary_phone = graphene.String(description="Secondary phone number") + email = graphene.String(description="Email address") + role = graphene.String(description="Role (admin, team_leader, team_member)") + + +class ProfileSearchInput(InputObjectType): + """ + Input type for searching profiles + """ + search_term = graphene.String(description="Search term to match against name, email, or phone") + role = graphene.String(description="Filter by role") + is_active = graphene.Boolean(description="Filter by active status") \ No newline at end of file diff --git a/backend/graphql_api/inputs/projects/__init__.py b/backend/graphql_api/inputs/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/projects/projects.py b/backend/graphql_api/inputs/projects/projects.py new file mode 100644 index 0000000..0d8d563 --- /dev/null +++ b/backend/graphql_api/inputs/projects/projects.py @@ -0,0 +1,53 @@ +import graphene +from graphene import InputObjectType + + +class ProjectCreateInput(InputObjectType): + """ + Input type for creating a new project. + """ + customer_id = graphene.ID(required=True, description="ID of the customer for this project") + account_id = graphene.ID(description="ID of the account for this project (optional)") + date = graphene.DateTime(required=True, description="Date of the project (YYYY-MM-DD)") + status = graphene.String(required=True, + description="Status of the project (planned, in_progress, completed, cancelled)") + team_member_ids = graphene.List(graphene.ID, required=True, + description="List of team member profile IDs assigned to this project") + notes = graphene.String(description="Notes about the project") + + # Financial + labor = graphene.Float(required=True, description="Labor cost for the project") + amount = graphene.Float(required=True, description="Total amount for the project") + + +class ProjectUpdateInput(InputObjectType): + """ + Input type for updating an existing project. + """ + id = graphene.ID(required=True, description="ID of the project to update") + customer_id = graphene.ID(description="ID of the customer for this project") + account_id = graphene.ID(description="ID of the account for this project") + date = graphene.DateTime(description="Date of the project (YYYY-MM-DD)") + status = graphene.String(description="Status of the project (planned, in_progress, completed, cancelled)") + team_member_ids = graphene.List(graphene.ID, description="List of team member profile IDs assigned to this project") + notes = graphene.String(description="Notes about the project") + + # Financial + labor = graphene.Float(description="Labor cost for the project") + amount = graphene.Float(description="Total amount for the project") + + +class ProjectFilterInput(InputObjectType): + """ + Input type for filtering projects. + """ + customer_id = graphene.ID(description="Filter by customer ID") + account_id = graphene.ID(description="Filter by account ID") + status = graphene.String(description="Filter by status") + team_member_id = graphene.ID(description="Filter by team member ID") + start_date = graphene.DateTime(description="Filter by start date (inclusive)") + end_date = graphene.DateTime(description="Filter by end date (inclusive)") + is_upcoming = graphene.Boolean(description="Filter for upcoming projects") + is_past_due = graphene.Boolean(description="Filter for past due projects") + min_amount = graphene.Float(description="Filter by minimum amount") + max_amount = graphene.Float(description="Filter by maximum amount") diff --git a/backend/graphql_api/inputs/punchlists/__init__.py b/backend/graphql_api/inputs/punchlists/__init__.py new file mode 100644 index 0000000..7380b0f --- /dev/null +++ b/backend/graphql_api/inputs/punchlists/__init__.py @@ -0,0 +1,3 @@ +""" +GraphQL input types for punchlists. +""" \ No newline at end of file diff --git a/backend/graphql_api/inputs/punchlists/punchlists.py b/backend/graphql_api/inputs/punchlists/punchlists.py new file mode 100644 index 0000000..fe0f257 --- /dev/null +++ b/backend/graphql_api/inputs/punchlists/punchlists.py @@ -0,0 +1,128 @@ +import graphene +from graphene import InputObjectType + + +class PunchlistCreateInput(InputObjectType): + """ + Input type for creating a new Punchlist. + """ + project_id = graphene.ID(required=True, description="ID of the project for this punchlist") + account_id = graphene.ID(required=True, description="ID of the account for this punchlist") + date = graphene.Date(required=True, description="Date of the punchlist (YYYY-MM-DD)") + second_visit = graphene.Boolean(description="Whether a second visit is required") + second_date = graphene.DateTime(description="Date and time of the second visit") + + # Front area section + front_ceiling = graphene.Boolean(description="Front area ceiling cleaned") + front_vents = graphene.Boolean(description="Front area vents cleaned") + front_fixtures = graphene.Boolean(description="Front area fixtures cleaned") + front_counter = graphene.Boolean(description="Front counter cleaned") + + # Main work area section + main_equipment = graphene.String(description="Main equipment type") + main_equipment_disassemble = graphene.Boolean(description="Equipment disassembled") + main_equipment_reassemble = graphene.Boolean(description="Equipment reassembled") + main_equipment_alerts = graphene.Boolean(description="Equipment alerts identified") + main_equipment_exterior = graphene.Boolean(description="Equipment exterior cleaned") + main_walls = graphene.Boolean(description="Main area walls cleaned") + main_fixtures = graphene.Boolean(description="Main area fixtures cleaned") + main_ceiling = graphene.Boolean(description="Main area ceiling cleaned") + main_vents = graphene.Boolean(description="Main area vents cleaned") + main_floors = graphene.Boolean(description="Main area floors cleaned") + + # Equipment section + equip_primary = graphene.Boolean(description="Primary equipment cleaned") + equip_station_1 = graphene.Boolean(description="Station 1 cleaned") + equip_station_2 = graphene.Boolean(description="Station 2 cleaned") + equip_station_3 = graphene.Boolean(description="Station 3 cleaned") + equip_storage = graphene.Boolean(description="Storage area cleaned") + equip_prep = graphene.Boolean(description="Prep area cleaned") + equip_delivery = graphene.Boolean(description="Delivery area cleaned") + equip_office = graphene.Boolean(description="Office area cleaned") + equip_sinks = graphene.Boolean(description="Sinks cleaned") + equip_dispensers = graphene.Boolean(description="Dispensers cleaned") + equip_other = graphene.Boolean(description="Other equipment cleaned") + + # Back area section + back_ceiling = graphene.Boolean(description="Back area ceiling cleaned") + back_vents = graphene.Boolean(description="Back area vents cleaned") + + # End of visit section + end_trash = graphene.Boolean(description="Trash removed") + end_clean = graphene.Boolean(description="Area left clean") + end_secure = graphene.Boolean(description="Area secured") + + # Notes + notes = graphene.String(description="Notes about the punchlist") + + +class PunchlistUpdateInput(InputObjectType): + """ + Input type for updating an existing Punchlist. + """ + id = graphene.ID(required=True, description="ID of the punchlist to update") + project_id = graphene.ID(description="ID of the project for this punchlist") + account_id = graphene.ID(description="ID of the account for this punchlist") + date = graphene.Date(description="Date of the punchlist (YYYY-MM-DD)") + second_visit = graphene.Boolean(description="Whether a second visit is required") + second_date = graphene.DateTime(description="Date and time of the second visit") + + # Front area section + front_ceiling = graphene.Boolean(description="Front area ceiling cleaned") + front_vents = graphene.Boolean(description="Front area vents cleaned") + front_fixtures = graphene.Boolean(description="Front area fixtures cleaned") + front_counter = graphene.Boolean(description="Front counter cleaned") + + # Main work area section + main_equipment = graphene.String(description="Main equipment type") + main_equipment_disassemble = graphene.Boolean(description="Equipment disassembled") + main_equipment_reassemble = graphene.Boolean(description="Equipment reassembled") + main_equipment_alerts = graphene.Boolean(description="Equipment alerts identified") + main_equipment_exterior = graphene.Boolean(description="Equipment exterior cleaned") + main_walls = graphene.Boolean(description="Main area walls cleaned") + main_fixtures = graphene.Boolean(description="Main area fixtures cleaned") + main_ceiling = graphene.Boolean(description="Main area ceiling cleaned") + main_vents = graphene.Boolean(description="Main area vents cleaned") + main_floors = graphene.Boolean(description="Main area floors cleaned") + + # Equipment section + equip_primary = graphene.Boolean(description="Primary equipment cleaned") + equip_station_1 = graphene.Boolean(description="Station 1 cleaned") + equip_station_2 = graphene.Boolean(description="Station 2 cleaned") + equip_station_3 = graphene.Boolean(description="Station 3 cleaned") + equip_storage = graphene.Boolean(description="Storage area cleaned") + equip_prep = graphene.Boolean(description="Prep area cleaned") + equip_delivery = graphene.Boolean(description="Delivery area cleaned") + equip_office = graphene.Boolean(description="Office area cleaned") + equip_sinks = graphene.Boolean(description="Sinks cleaned") + equip_dispensers = graphene.Boolean(description="Dispensers cleaned") + equip_other = graphene.Boolean(description="Other equipment cleaned") + + # Back area section + back_ceiling = graphene.Boolean(description="Back area ceiling cleaned") + back_vents = graphene.Boolean(description="Back area vents cleaned") + + # End of visit section + end_trash = graphene.Boolean(description="Trash removed") + end_clean = graphene.Boolean(description="Area left clean") + end_secure = graphene.Boolean(description="Area secured") + + # Notes + notes = graphene.String(description="Notes about the punchlist") + + # Export fields + exported_at = graphene.DateTime(description="When the punchlist was exported") + sheet_url = graphene.String(description="URL to the Google Sheet") + pdf_url = graphene.String(description="URL to the PDF") + + +class PunchlistFilterInput(InputObjectType): + """ + Input type for filtering Punchlists. + """ + project_id = graphene.ID(description="Filter by project ID") + account_id = graphene.ID(description="Filter by account ID") + start_date = graphene.Date(description="Filter by start date (inclusive)") + end_date = graphene.Date(description="Filter by end date (inclusive)") + is_exported = graphene.Boolean(description="Filter by export status") + second_visit = graphene.Boolean(description="Filter by second visit requirement") diff --git a/backend/graphql_api/inputs/reports/__init__.py b/backend/graphql_api/inputs/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/reports/reports.py b/backend/graphql_api/inputs/reports/reports.py new file mode 100644 index 0000000..e65b245 --- /dev/null +++ b/backend/graphql_api/inputs/reports/reports.py @@ -0,0 +1,62 @@ +import graphene +from graphene import InputObjectType + +class ReportCreateInput(InputObjectType): + """ + Input type for creating a new report. + """ + date = graphene.String(required=True, description="Date of the report (YYYY-MM-DD)") + team_member_id = graphene.ID(required=True, description="ID of the team member submitting the report") + service_ids = graphene.List(graphene.ID, description="List of service IDs included in this report") + project_ids = graphene.List(graphene.ID, description="List of project IDs included in this report") + notes = graphene.String(description="Notes about the report") + + +class ReportUpdateInput(InputObjectType): + """ + Input type for updating an existing report. + """ + id = graphene.ID(required=True, description="ID of the report to update") + date = graphene.String(description="Date of the report (YYYY-MM-DD)") + team_member_id = graphene.ID(description="ID of the team member submitting the report") + service_ids = graphene.List(graphene.ID, description="List of service IDs included in this report") + project_ids = graphene.List(graphene.ID, description="List of project IDs included in this report") + notes = graphene.String(description="Notes about the report") + + +class TeamMemberReportsInput(InputObjectType): + """ + Input type for retrieving team member reports. + """ + team_member_id = graphene.ID(required=True, description="ID of the team member") + start_date = graphene.String(description="Start date for report range (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date for report range (YYYY-MM-DD)") + + +class TeamMemberActivityInput(InputObjectType): + """ + Input type for retrieving team member activity. + """ + team_member_id = graphene.ID(required=True, description="ID of the team member") + start_date = graphene.DateTime(description="Start date for activity range (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date for activity range (YYYY-MM-DD)") + + +class TeamSummaryInput(InputObjectType): + """ + Input type for retrieving team summary. + """ + start_date = graphene.DateTime(description="Start date for summary range (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date for summary range (YYYY-MM-DD)") + include_inactive = graphene.Boolean(description="Whether to include inactive team members") + +import graphene + + +class ReportFilterInput(graphene.InputObjectType): + """Input type for filtering reports""" + team_member_id = graphene.ID(description="Filter by team member ID") + start_date = graphene.DateTime(description="Filter by start date") + end_date = graphene.DateTime(description="Filter by end date") + has_services = graphene.Boolean(description="Filter by presence of services") + has_projects = graphene.Boolean(description="Filter by presence of projects") diff --git a/backend/graphql_api/inputs/revenues/__init__.py b/backend/graphql_api/inputs/revenues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/revenues/revenues.py b/backend/graphql_api/inputs/revenues/revenues.py new file mode 100644 index 0000000..fde4f76 --- /dev/null +++ b/backend/graphql_api/inputs/revenues/revenues.py @@ -0,0 +1,58 @@ +import graphene +from graphene import InputObjectType + +class RevenueCreateInput(InputObjectType): + """ + Input type for creating a new revenue record. + """ + account_id = graphene.ID(required=True, description="ID of the account this revenue belongs to") + amount = graphene.String(required=True, description="Revenue amount") + start_date = graphene.DateTime(required=True, description="Start date of the revenue period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the revenue period (YYYY-MM-DD)") + + +class RevenueUpdateInput(InputObjectType): + """ + Input type for updating an existing revenue record. + """ + id = graphene.ID(required=True, description="ID of the revenue record to update") + account_id = graphene.ID(description="ID of the account this revenue belongs to") + amount = graphene.String(description="Revenue amount") + start_date = graphene.DateTime(description="Start date of the revenue period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the revenue period (YYYY-MM-DD)") + + +class RevenueEndInput(InputObjectType): + """ + Input type for ending a revenue period. + """ + id = graphene.ID(required=True, description="ID of the revenue record to end") + end_date = graphene.DateTime(description="End date of the revenue period (YYYY-MM-DD), defaults to today if not provided") + + +class RevenueByDateRangeInput(InputObjectType): + """ + Input type for retrieving revenues by date range. + """ + start_date = graphene.DateTime(description="Start date for the period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date for the period (YYYY-MM-DD)") + account_id = graphene.ID(description="Filter by account ID") + + +class CalculateTotalRevenueInput(InputObjectType): + """ + Input type for calculating total revenue. + """ + account_id = graphene.ID(description="Calculate revenue for a specific account") + start_date = graphene.DateTime(description="Start date for calculation period (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date for calculation period (YYYY-MM-DD)") + + +class RevenueFilterInput(graphene.InputObjectType): + """Input type for filtering revenue records""" + account_id = graphene.ID(description="Filter by account ID") + is_active = graphene.Boolean(description="Filter by active status") + start_date = graphene.DateTime(description="Filter by start date") + end_date = graphene.DateTime(description="Filter by end date") + amount_min = graphene.Float(description="Filter by minimum amount") + amount_max = graphene.Float(description="Filter by maximum amount") diff --git a/backend/graphql_api/inputs/schedules/__init__.py b/backend/graphql_api/inputs/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/schedules/schedules.py b/backend/graphql_api/inputs/schedules/schedules.py new file mode 100644 index 0000000..9de1f9b --- /dev/null +++ b/backend/graphql_api/inputs/schedules/schedules.py @@ -0,0 +1,70 @@ +import graphene +from graphene import InputObjectType + + +class ScheduleCreateInput(InputObjectType): + """ + Input type for creating a new schedule. + """ + account_id = graphene.ID(required=True, description="ID of the account this schedule belongs to") + + # Service days + monday_service = graphene.Boolean(description="Whether service is scheduled on Monday", default_value=False) + tuesday_service = graphene.Boolean(description="Whether service is scheduled on Tuesday", default_value=False) + wednesday_service = graphene.Boolean(description="Whether service is scheduled on Wednesday", default_value=False) + thursday_service = graphene.Boolean(description="Whether service is scheduled on Thursday", default_value=False) + friday_service = graphene.Boolean(description="Whether service is scheduled on Friday", default_value=False) + saturday_service = graphene.Boolean(description="Whether service is scheduled on Saturday", default_value=False) + sunday_service = graphene.Boolean(description="Whether service is scheduled on Sunday", default_value=False) + weekend_service = graphene.Boolean(description="Whether weekend service is enabled", default_value=False) + + # Exceptions + schedule_exception = graphene.String(description="Exceptions to the regular schedule") + + # Dates + start_date = graphene.DateTime(required=True, description="Start date of the schedule (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the schedule (YYYY-MM-DD)") + + +class ScheduleUpdateInput(InputObjectType): + """ + Input type for updating an existing schedule. + """ + id = graphene.ID(required=True, description="ID of the schedule to update") + account_id = graphene.ID(description="ID of the account this schedule belongs to") + + # Service days + monday_service = graphene.Boolean(description="Whether service is scheduled on Monday") + tuesday_service = graphene.Boolean(description="Whether service is scheduled on Tuesday") + wednesday_service = graphene.Boolean(description="Whether service is scheduled on Wednesday") + thursday_service = graphene.Boolean(description="Whether service is scheduled on Thursday") + friday_service = graphene.Boolean(description="Whether service is scheduled on Friday") + saturday_service = graphene.Boolean(description="Whether service is scheduled on Saturday") + sunday_service = graphene.Boolean(description="Whether service is scheduled on Sunday") + weekend_service = graphene.Boolean(description="Whether weekend service is enabled") + + # Exceptions + schedule_exception = graphene.String(description="Exceptions to the regular schedule") + + # Dates + start_date = graphene.DateTime(description="Start date of the schedule (YYYY-MM-DD)") + end_date = graphene.DateTime(description="End date of the schedule (YYYY-MM-DD)") + + +class GenerateServicesInput(InputObjectType): + """ + Input type for generating services based on a schedule. + """ + schedule_id = graphene.ID(required=True, description="ID of the schedule to generate services from") + start_date = graphene.DateTime(required=True, description="Start date for service generation (YYYY-MM-DD)") + end_date = graphene.DateTime(required=True, description="End date for service generation (YYYY-MM-DD)") + + +class ScheduleFilterInput(graphene.InputObjectType): + """Input type for filtering schedules""" + account_id = graphene.ID(description="Filter by account ID") + is_active = graphene.Boolean(description="Filter by active status") + start_date = graphene.DateTime(description="Filter by start date") + end_date = graphene.DateTime(description="Filter by end date") + has_exceptions = graphene.Boolean(description="Filter by presence of schedule exceptions") + has_weekend_service = graphene.Boolean(description="Filter by weekend service availability") diff --git a/backend/graphql_api/inputs/services/__init__.py b/backend/graphql_api/inputs/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/inputs/services/services.py b/backend/graphql_api/inputs/services/services.py new file mode 100644 index 0000000..0227333 --- /dev/null +++ b/backend/graphql_api/inputs/services/services.py @@ -0,0 +1,48 @@ +import graphene +from graphene import InputObjectType + + +class ServiceCreateInput(InputObjectType): + """ + Input type for creating a new service. + """ + account_id = graphene.ID(required=True, description="ID of the account this service is for") + date = graphene.String(required=True, description="Date of the service (YYYY-MM-DD)") + status = graphene.String(description="Status of the service (scheduled, in_progress, completed, cancelled)", + default_value="scheduled") + team_member_ids = graphene.List(graphene.ID, description="List of team member profile IDs assigned to this service") + notes = graphene.String(description="Notes about the service") + + # Service window + deadline_start = graphene.DateTime(required=True, description="Start time of service window (YYYY-MM-DD HH:MM:SS)") + deadline_end = graphene.DateTime(required=True, description="End time of service window (YYYY-MM-DD HH:MM:SS)") + + +class ServiceUpdateInput(InputObjectType): + """ + Input type for updating an existing service. + """ + id = graphene.ID(required=True, description="ID of the service to update") + account_id = graphene.ID(description="ID of the account this service is for") + date = graphene.String(description="Date of the service (YYYY-MM-DD)") + status = graphene.String(description="Status of the service (scheduled, in_progress, completed, cancelled)") + team_member_ids = graphene.List(graphene.ID, description="List of team member profile IDs assigned to this service") + notes = graphene.String(description="Notes about the service") + + # Service window + deadline_start = graphene.DateTime(description="Start time of service window (YYYY-MM-DD HH:MM:SS)") + deadline_end = graphene.DateTime(description="End time of service window (YYYY-MM-DD HH:MM:SS)") + + +class ServiceFilterInput(InputObjectType): + """ + Input type for filtering services. + """ + account_id = graphene.ID(description="Filter by account ID") + status = graphene.String(description="Filter by status") + team_member_id = graphene.ID(description="Filter by team member ID") + start_date = graphene.DateTime(description="Filter by start date (inclusive)") + end_date = graphene.DateTime(description="Filter by end date (inclusive)") + is_upcoming = graphene.Boolean(description="Filter for upcoming services") + is_today = graphene.Boolean(description="Filter for today's services") + is_past_due = graphene.Boolean(description="Filter for past due services") diff --git a/backend/graphql_api/mutations/__init__.py b/backend/graphql_api/mutations/__init__.py new file mode 100644 index 0000000..1862fe5 --- /dev/null +++ b/backend/graphql_api/mutations/__init__.py @@ -0,0 +1,112 @@ +from backend.graphql_api.mutations.accounts.accounts import ( + CreateAccountMutation, + UpdateAccountMutation, + DeleteAccountMutation, + MarkAccountInactiveMutation +) +from backend.graphql_api.mutations.customers.customers import ( + CreateCustomerMutation, + UpdateCustomerMutation, + DeleteCustomerMutation +) +from backend.graphql_api.mutations.services.services import ( + CreateServiceMutation, + UpdateServiceMutation, + DeleteServiceMutation +) +from backend.graphql_api.mutations.projects.projects import ( + CreateProjectMutation, + UpdateProjectMutation, + DeleteProjectMutation +) +from backend.graphql_api.mutations.invoices.invoices import ( + CreateInvoiceMutation, + CancelInvoiceMutation, + SendInvoiceMutation, + MarkInvoicePaidMutation, +) +from backend.graphql_api.mutations.labor.labor import ( + CreateLaborMutation, + UpdateLaborMutation, + DeleteLaborMutation, + EndLaborMutation +) +from backend.graphql_api.mutations.profiles.profiles import ( + CreateProfileMutation, + UpdateProfileMutation, + DeleteProfileMutation +) +from backend.graphql_api.mutations.reports.reports import ( + CreateReportMutation, + UpdateReportMutation, + DeleteReportMutation +) +from backend.graphql_api.mutations.revenues.revenues import ( + CreateRevenueMutation, + UpdateRevenueMutation, + DeleteRevenueMutation, + EndRevenueMutation +) +from backend.graphql_api.mutations.schedules.schedules import ( + CreateScheduleMutation, + UpdateScheduleMutation, + DeleteScheduleMutation, + GenerateServicesMutation +) + +__all__ = [ + # Account mutations + 'CreateAccountMutation', + 'UpdateAccountMutation', + 'DeleteAccountMutation', + 'MarkAccountInactiveMutation', + + # Customer mutations + 'CreateCustomerMutation', + 'UpdateCustomerMutation', + 'DeleteCustomerMutation', + + # Service mutations + 'CreateServiceMutation', + 'UpdateServiceMutation', + 'DeleteServiceMutation', + + # Project mutations + 'CreateProjectMutation', + 'UpdateProjectMutation', + 'DeleteProjectMutation', + + # Invoice mutations + 'CreateInvoiceMutation', + 'CancelInvoiceMutation', + 'SendInvoiceMutation', + 'MarkInvoicePaidMutation', + + # Labor mutations + 'CreateLaborMutation', + 'UpdateLaborMutation', + 'DeleteLaborMutation', + 'EndLaborMutation', + + # Profile mutations + 'CreateProfileMutation', + 'UpdateProfileMutation', + 'DeleteProfileMutation', + + # Report mutations + 'CreateReportMutation', + 'UpdateReportMutation', + 'DeleteReportMutation', + + # Revenue mutations + 'CreateRevenueMutation', + 'UpdateRevenueMutation', + 'DeleteRevenueMutation', + 'EndRevenueMutation', + + # Schedule mutations + 'CreateScheduleMutation', + 'UpdateScheduleMutation', + 'DeleteScheduleMutation', + 'GenerateServicesMutation', +] diff --git a/backend/graphql_api/mutations/accounts/__init__.py b/backend/graphql_api/mutations/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/accounts/accounts.py b/backend/graphql_api/mutations/accounts/accounts.py new file mode 100644 index 0000000..a8abc67 --- /dev/null +++ b/backend/graphql_api/mutations/accounts/accounts.py @@ -0,0 +1,188 @@ +import graphene +from graphene import Field, Boolean, List, String + +from backend.core.commands import ( + CreateAccountCommand, + UpdateAccountCommand, + DeleteAccountCommand, + MarkAccountInactiveCommand +) +from backend.core.repositories import AccountRepository, CustomerRepository, RevenueRepository +from backend.core.utils import parse_date +from backend.graphql_api.types import AccountType +from backend.graphql_api.inputs import AccountCreateInput, AccountUpdateInput + + +class CreateAccountMutation(graphene.Mutation): + """Mutation to create a new account.""" + + class Arguments: + input = AccountCreateInput(required=True) + + account = Field(AccountType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + account_repo = AccountRepository() + customer_repo = CustomerRepository() + command = CreateAccountCommand( + account_repo=account_repo, + customer_repo=customer_repo, + customer_id=input.customer_id, + name=input.name, + street_address=input.street_address, + city=input.city, + state=input.state, + zip_code=input.zip_code, + primary_contact_first_name=input.primary_contact_first_name, + primary_contact_last_name=input.primary_contact_last_name, + primary_contact_phone=input.primary_contact_phone, + primary_contact_email=input.primary_contact_email, + secondary_contact_first_name=input.secondary_contact_first_name, + secondary_contact_last_name=input.secondary_contact_last_name, + secondary_contact_phone=input.secondary_contact_phone, + secondary_contact_email=input.secondary_contact_email, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'account': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + + +class UpdateAccountMutation(graphene.Mutation): + """Mutation to update an existing account.""" + + class Arguments: + input = AccountUpdateInput(required=True) + + account = Field(AccountType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + account_repo = AccountRepository() + command = UpdateAccountCommand( + account_repo=account_repo, + account_id=input.id, + name=input.name, + street_address=input.street_address, + city=input.city, + state=input.state, + zip_code=input.zip_code, + primary_contact_first_name=input.primary_contact_first_name, + primary_contact_last_name=input.primary_contact_last_name, + primary_contact_phone=input.primary_contact_phone, + primary_contact_email=input.primary_contact_email, + secondary_contact_first_name=input.secondary_contact_first_name, + secondary_contact_last_name=input.secondary_contact_last_name, + secondary_contact_phone=input.secondary_contact_phone, + secondary_contact_email=input.secondary_contact_email, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'account': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + + +class DeleteAccountMutation(graphene.Mutation): + """Mutation to delete an account.""" + + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + account_repo = AccountRepository() + command = DeleteAccountCommand(account_repo=account_repo, account_id=id) + result = command.execute() + return { + 'account': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + + +class MarkAccountInactiveMutation(graphene.Mutation): + """Mutation to mark an account as inactive.""" + + class Arguments: + id = graphene.ID(required=True) + end_date = graphene.String() + + account = Field(AccountType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, end_date=None): + account_repo = AccountRepository() + command = MarkAccountInactiveCommand( + account_repo=account_repo, + account_id=id, + end_date=end_date + ) + result = command.execute() + return { + 'account': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + + +class GetAccountRevenueMutation(graphene.Mutation): + """Mutation to get revenue information for an account.""" + + class Arguments: + account_id = graphene.ID(required=True, description="ID of the account to get revenue for") + start_date = graphene.String(description="Start date for revenue calculation") + end_date = graphene.String(description="End date for revenue calculation") + + total_revenue = graphene.Float(description="Total revenue for the account") + success = graphene.Boolean(description="Whether the operation was successful") + errors = graphene.List(graphene.String, description="List of errors that occurred") + message = graphene.String(description="Success or error message") + + @staticmethod + def mutate(root, info, account_id, start_date=None, end_date=None): + try: + revenue_repo = RevenueRepository() + total = revenue_repo.get_total_revenue( + account_id=account_id, + start_date=parse_date(start_date) if start_date else None, + end_date=parse_date(end_date) if end_date else None + ) + return { + 'total_revenue': total, + 'success': True, + 'errors': [], + 'message': f"Successfully calculated revenue for account {account_id}" + } + except Exception as e: + return { + 'total_revenue': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while calculating account revenue" + } diff --git a/backend/graphql_api/mutations/auth/__init__.py b/backend/graphql_api/mutations/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/auth/auth.py b/backend/graphql_api/mutations/auth/auth.py new file mode 100644 index 0000000..4836f1e --- /dev/null +++ b/backend/graphql_api/mutations/auth/auth.py @@ -0,0 +1,85 @@ +import graphene +import graphql_jwt +from graphene import String, Boolean, List + +class TokenAuthMutation(graphene.Mutation): + """Mutation to obtain JWT token.""" + class Arguments: + username = String(required=True) + password = String(required=True) + + token = String() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, username, password): + try: + result = graphql_jwt.ObtainJSONWebToken.mutate(root, info, username=username, password=password) + return { + 'token': result.token, + 'success': True, + 'errors': [], + 'message': "Token obtained successfully" + } + except Exception as e: + return { + 'token': None, + 'success': False, + 'errors': [str(e)], + 'message': "Failed to obtain token" + } + +class VerifyTokenMutation(graphene.Mutation): + """Mutation to verify JWT token.""" + class Arguments: + token = String(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, token): + try: + graphql_jwt.Verify.mutate(root, info, token=token) + return { + 'success': True, + 'errors': [], + 'message': "Token verified successfully" + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "Token verification failed" + } + +class RefreshTokenMutation(graphene.Mutation): + """Mutation to refresh JWT token.""" + class Arguments: + token = String(required=True) + + token = String() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, token): + try: + result = graphql_jwt.Refresh.mutate(root, info, token=token) + return { + 'token': result.token, + 'success': True, + 'errors': [], + 'message': "Token refreshed successfully" + } + except Exception as e: + return { + 'token': None, + 'success': False, + 'errors': [str(e)], + 'message': "Token refresh failed" + } diff --git a/backend/graphql_api/mutations/customers/__init__.py b/backend/graphql_api/mutations/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/customers/customers.py b/backend/graphql_api/mutations/customers/customers.py new file mode 100644 index 0000000..b099431 --- /dev/null +++ b/backend/graphql_api/mutations/customers/customers.py @@ -0,0 +1,187 @@ +import graphene +from graphene import Field, Boolean, List, String + +from backend.core.commands import ( + CreateCustomerCommand, + UpdateCustomerCommand, + DeleteCustomerCommand, + MarkCustomerInactiveCommand +) +from backend.core.repositories import CustomerRepository, AccountRepository +from backend.graphql_api.types import CustomerType +from backend.graphql_api.inputs import CustomerCreateInput, CustomerUpdateInput + + +class CreateCustomerMutation(graphene.Mutation): + """Mutation to create a new customer.""" + class Arguments: + input = CustomerCreateInput(required=True) + + customer = Field(CustomerType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + customer_repo = CustomerRepository() + command = CreateCustomerCommand( + customer_repo=customer_repo, + name=input.name, + primary_contact_first_name=input.primary_contact_first_name, + primary_contact_last_name=input.primary_contact_last_name, + primary_contact_phone=input.primary_contact_phone, + primary_contact_email=input.primary_contact_email, + secondary_contact_first_name=input.secondary_contact_first_name, + secondary_contact_last_name=input.secondary_contact_last_name, + secondary_contact_phone=input.secondary_contact_phone, + secondary_contact_email=input.secondary_contact_email, + billing_contact_first_name=input.billing_contact_first_name, + billing_contact_last_name=input.billing_contact_last_name, + billing_street_address=input.billing_street_address, + billing_city=input.billing_city, + billing_state=input.billing_state, + billing_zip_code=input.billing_zip_code, + billing_email=input.billing_email, + billing_terms=input.billing_terms, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'customer': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'customer': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the customer" + } + + +class UpdateCustomerMutation(graphene.Mutation): + """Mutation to update an existing customer.""" + class Arguments: + id = graphene.ID(required=True) + input = CustomerUpdateInput(required=True) + + customer = Field(CustomerType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, input): + try: + customer_repo = CustomerRepository() + command = UpdateCustomerCommand( + id=id, + customer_repo=customer_repo, + name=input.name, + primary_contact_first_name=input.primary_contact_first_name, + primary_contact_last_name=input.primary_contact_last_name, + primary_contact_phone=input.primary_contact_phone, + primary_contact_email=input.primary_contact_email, + secondary_contact_first_name=input.secondary_contact_first_name, + secondary_contact_last_name=input.secondary_contact_last_name, + secondary_contact_phone=input.secondary_contact_phone, + secondary_contact_email=input.secondary_contact_email, + billing_contact_first_name=input.billing_contact_first_name, + billing_contact_last_name=input.billing_contact_last_name, + billing_street_address=input.billing_street_address, + billing_city=input.billing_city, + billing_state=input.billing_state, + billing_zip_code=input.billing_zip_code, + billing_email=input.billing_email, + billing_terms=input.billing_terms, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'customer': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'customer': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the customer" + } + + +class DeleteCustomerMutation(graphene.Mutation): + """Mutation to delete a customer.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + customer_repo = CustomerRepository() + account_repo = AccountRepository() + command = DeleteCustomerCommand( + customer_repo=customer_repo, + account_repo=account_repo, + customer_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the customer" + } + + +class MarkCustomerInactiveMutation(graphene.Mutation): + """Mutation to mark a customer as inactive.""" + class Arguments: + id = graphene.ID(required=True) + end_date = graphene.String() + + customer = Field(CustomerType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, end_date=None): + try: + customer_repo = CustomerRepository() + command = MarkCustomerInactiveCommand( + customer_repo=customer_repo, + customer_id=id, + end_date=end_date + ) + result = command.execute() + return { + 'customer': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'customer': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while marking the customer as inactive" + } diff --git a/backend/graphql_api/mutations/invoices/__init__.py b/backend/graphql_api/mutations/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/invoices/invoices.py b/backend/graphql_api/mutations/invoices/invoices.py new file mode 100644 index 0000000..2b7af91 --- /dev/null +++ b/backend/graphql_api/mutations/invoices/invoices.py @@ -0,0 +1,158 @@ +import graphene +from graphene import Field, Boolean, List, String + +from backend.core.commands import ( + CreateInvoiceCommand, + SendInvoiceCommand, + MarkInvoicePaidCommand, + CancelInvoiceCommand +) +from backend.core.repositories import InvoiceRepository, CustomerRepository +from backend.graphql_api.types import InvoiceType +from backend.graphql_api.inputs import InvoiceCreateInput + + +class CreateInvoiceMutation(graphene.Mutation): + """Mutation to create a new invoice.""" + class Arguments: + input = InvoiceCreateInput(required=True) + + invoice = Field(InvoiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + invoice_repo = InvoiceRepository() + customer_repo = CustomerRepository() + command = CreateInvoiceCommand( + invoice_repo=invoice_repo, + customer_repo=customer_repo, + customer_id=input.customer_id, + invoice_date=input.date, + account_ids=input.account_ids, + project_ids=input.project_ids, + total_amount=input.total_amount + ) + result = command.execute() + return { + 'invoice': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'invoice': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the invoice" + } + + +class SendInvoiceMutation(graphene.Mutation): + """Mutation to send an invoice.""" + class Arguments: + id = graphene.ID(required=True) + + invoice = Field(InvoiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + invoice_repo = InvoiceRepository() + command = SendInvoiceCommand( + invoice_repo=invoice_repo, + invoice_id=id + ) + result = command.execute() + return { + 'invoice': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'invoice': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while sending the invoice" + } + + +class MarkInvoicePaidMutation(graphene.Mutation): + """Mutation to mark an invoice as paid.""" + class Arguments: + id = graphene.ID(required=True) + payment_type = graphene.String(required=True) + date_paid = graphene.String() + + invoice = Field(InvoiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, payment_type, date_paid=None): + try: + invoice_repo = InvoiceRepository() + command = MarkInvoicePaidCommand( + invoice_repo=invoice_repo, + invoice_id=id, + payment_type=payment_type, + date_paid=date_paid + ) + result = command.execute() + return { + 'invoice': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'invoice': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while marking the invoice as paid" + } + + +class CancelInvoiceMutation(graphene.Mutation): + """Mutation to cancel an invoice.""" + class Arguments: + id = graphene.ID(required=True) + + invoice = Field(InvoiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + invoice_repo = InvoiceRepository() + command = CancelInvoiceCommand( + invoice_repo=invoice_repo, + invoice_id=id + ) + result = command.execute() + return { + 'invoice': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'invoice': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while cancelling the invoice" + } diff --git a/backend/graphql_api/mutations/labor/__init__.py b/backend/graphql_api/mutations/labor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/labor/labor.py b/backend/graphql_api/mutations/labor/labor.py new file mode 100644 index 0000000..fca7215 --- /dev/null +++ b/backend/graphql_api/mutations/labor/labor.py @@ -0,0 +1,198 @@ +import graphene +from graphene import Field, Boolean, List, String, Float + +from backend.core.commands import ( + CreateLaborCommand, + UpdateLaborCommand, + DeleteLaborCommand, + EndLaborCommand, + CalculateLaborCostCommand +) +from backend.core.repositories import LaborRepository, AccountRepository +from backend.graphql_api.types import LaborType +from backend.graphql_api.inputs import LaborCreateInput, LaborUpdateInput + + +class CreateLaborMutation(graphene.Mutation): + """Mutation to create a new labor record.""" + class Arguments: + input = LaborCreateInput(required=True) + + labor = Field(LaborType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + labor_repo = LaborRepository() + account_repo = AccountRepository() + command = CreateLaborCommand( + labor_repo=labor_repo, + account_repo=account_repo, + account_id=input.account_id, + amount=input.amount, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'labor': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'labor': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the labor record" + } + + +class UpdateLaborMutation(graphene.Mutation): + """Mutation to update an existing labor record.""" + class Arguments: + input = LaborUpdateInput(required=True) + + labor = Field(LaborType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + labor_repo = LaborRepository() + account_repo = AccountRepository() + command = UpdateLaborCommand( + labor_repo=labor_repo, + account_repo=account_repo, + labor_id=input.id, + amount=input.amount, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'labor': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'labor': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the labor record" + } + + +class DeleteLaborMutation(graphene.Mutation): + """Mutation to delete a labor record.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + labor_repo = LaborRepository() + command = DeleteLaborCommand( + labor_repo=labor_repo, + labor_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the labor record" + } + + +class EndLaborMutation(graphene.Mutation): + """Mutation to end a labor record.""" + class Arguments: + id = graphene.ID(required=True) + end_date = graphene.String() + + labor = Field(LaborType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, end_date=None): + try: + labor_repo = LaborRepository() + command = EndLaborCommand( + labor_repo=labor_repo, + labor_id=id, + end_date=end_date + ) + result = command.execute() + return { + 'labor': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'labor': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while ending the labor record" + } + + +class CalculateLaborCostMutation(graphene.Mutation): + """Mutation to calculate labor cost.""" + class Arguments: + account_id = graphene.ID() + start_date = graphene.String() + end_date = graphene.String() + + total_cost = Float() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, account_id=None, start_date=None, end_date=None): + try: + labor_repo = LaborRepository() + account_repo = AccountRepository() + command = CalculateLaborCostCommand( + labor_repo=labor_repo, + account_repo=account_repo, + account_id=account_id, + start_date=start_date, + end_date=end_date + ) + result = command.execute() + return { + 'total_cost': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'total_cost': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while calculating labor cost" + } diff --git a/backend/graphql_api/mutations/profiles/__init__.py b/backend/graphql_api/mutations/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/profiles/profiles.py b/backend/graphql_api/mutations/profiles/profiles.py new file mode 100644 index 0000000..929a850 --- /dev/null +++ b/backend/graphql_api/mutations/profiles/profiles.py @@ -0,0 +1,169 @@ +import graphene +from django.contrib.auth.models import User +from graphene import Field, Boolean, List, String + +from backend.core.commands import ( + CreateProfileCommand, + UpdateProfileCommand, + DeleteProfileCommand, + SearchProfilesCommand +) +from backend.core.repositories import ProfileRepository +from backend.graphql_api.types import ProfileType +from backend.graphql_api.inputs import ProfileCreateInput, ProfileUpdateInput + + +class CreateProfileMutation(graphene.Mutation): + """Mutation to create a new profile.""" + class Arguments: + input = ProfileCreateInput(required=True) + + profile = Field(ProfileType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + try: + user = User.objects.get(id=input.user_id) + except User.DoesNotExist: + return { + 'profile': None, + 'success': False, + 'errors': ['User not found'], + 'message': "User with the specified ID does not exist" + } + profile_repo = ProfileRepository() + command = CreateProfileCommand( + profile_repo=profile_repo, + user=user, + first_name=input.first_name, + last_name=input.last_name, + primary_phone=input.primary_phone, + email=input.email, + role=input.role, + secondary_phone=input.secondary_phone + ) + result = command.execute() + return { + 'profile': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'profile': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the profile" + } + + +class UpdateProfileMutation(graphene.Mutation): + """Mutation to update an existing profile.""" + class Arguments: + input = ProfileUpdateInput(required=True) + + profile = Field(ProfileType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + profile_repo = ProfileRepository() + command = UpdateProfileCommand( + profile_repo=profile_repo, + profile_id=input.id, + first_name=input.first_name, + last_name=input.last_name, + primary_phone=input.primary_phone, + email=input.email, + role=input.role, + secondary_phone=input.secondary_phone + ) + result = command.execute() + return { + 'profile': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'profile': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the profile" + } + + +class DeleteProfileMutation(graphene.Mutation): + """Mutation to delete a profile.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + profile_repo = ProfileRepository() + command = DeleteProfileCommand( + profile_repo=profile_repo, + profile_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the profile" + } + + +class SearchProfilesMutation(graphene.Mutation): + """Mutation to search profiles.""" + class Arguments: + search_term = graphene.String(required=True) + role = graphene.String() + + profiles = List(ProfileType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, search_term, role=None): + try: + profile_repo = ProfileRepository() + command = SearchProfilesCommand( + profile_repo=profile_repo, + search_term=search_term, + role=role + ) + result = command.execute() + return { + 'profiles': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'profiles': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while searching profiles" + } diff --git a/backend/graphql_api/mutations/projects/__init__.py b/backend/graphql_api/mutations/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/projects/projects.py b/backend/graphql_api/mutations/projects/projects.py new file mode 100644 index 0000000..a1e5b81 --- /dev/null +++ b/backend/graphql_api/mutations/projects/projects.py @@ -0,0 +1,143 @@ +import graphene +from graphene import Field, Boolean, List, String + +from backend.core.commands import ( + CreateProjectCommand, + UpdateProjectCommand, + DeleteProjectCommand +) +from backend.core.repositories import ( + ProjectRepository, + CustomerRepository, + AccountRepository, + ProfileRepository +) +from backend.graphql_api.types import ProjectType +from backend.graphql_api.inputs import ProjectCreateInput, ProjectUpdateInput + + +class CreateProjectMutation(graphene.Mutation): + """Mutation to create a new project.""" + class Arguments: + input = ProjectCreateInput(required=True) + + project = Field(ProjectType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + project_repo = ProjectRepository() + customer_repo = CustomerRepository() + account_repo = AccountRepository() + profile_repo = ProfileRepository() + + command = CreateProjectCommand( + project_repo=project_repo, + customer_repo=customer_repo, + account_repo=account_repo, + profile_repo=profile_repo, + customer_id=input.customer_id, + date=input.date, + labor=input.labor, + status=input.status, + account_id=input.account_id, + team_member_ids=input.team_member_ids, + notes=input.notes, + amount=input.amount + ) + result = command.execute() + return { + 'project': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'project': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the project" + } + + +class UpdateProjectMutation(graphene.Mutation): + """Mutation to update an existing project.""" + class Arguments: + input = ProjectUpdateInput(required=True) + + project = Field(ProjectType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + project_repo = ProjectRepository() + customer_repo = CustomerRepository() + account_repo = AccountRepository() + profile_repo = ProfileRepository() + + command = UpdateProjectCommand( + project_repo=project_repo, + customer_repo=customer_repo, + account_repo=account_repo, + profile_repo=profile_repo, + project_id=input.id, + status=input.status, + date=input.date, + labor=input.labor, + account_id=input.account_id, + team_member_ids=input.team_member_ids, + notes=input.notes, + amount=input.amount + ) + result = command.execute() + return { + 'project': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'project': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the project" + } + + +class DeleteProjectMutation(graphene.Mutation): + """Mutation to delete a project.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + project_repo = ProjectRepository() + command = DeleteProjectCommand( + project_repo=project_repo, + project_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the project" + } diff --git a/backend/graphql_api/mutations/reports/__init__.py b/backend/graphql_api/mutations/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/reports/reports.py b/backend/graphql_api/mutations/reports/reports.py new file mode 100644 index 0000000..79fbcee --- /dev/null +++ b/backend/graphql_api/mutations/reports/reports.py @@ -0,0 +1,218 @@ +import graphene +from graphene import Field, Boolean, List, String, JSONString + +from backend.core.commands import ( + CreateReportCommand, + UpdateReportCommand, + DeleteReportCommand, + GetTeamMemberReportsCommand, + GetTeamMemberActivityCommand, + GetTeamSummaryCommand +) +from backend.graphql_api.types import ReportType +from backend.graphql_api.inputs import ReportCreateInput, ReportUpdateInput + + +class CreateReportMutation(graphene.Mutation): + """Mutation to create a new report.""" + class Arguments: + input = ReportCreateInput(required=True) + + report = Field(ReportType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + command = CreateReportCommand( + team_member_id=input.team_member_id, + report_date=input.report_date, + service_ids=input.service_ids, + project_ids=input.project_ids, + notes=input.notes + ) + result = command.execute() + return { + 'report': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'report': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the report" + } + + +class UpdateReportMutation(graphene.Mutation): + """Mutation to update an existing report.""" + class Arguments: + input = ReportUpdateInput(required=True) + + report = Field(ReportType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + command = UpdateReportCommand( + report_id=input.id, + team_member_id=input.team_member_id, + report_date=input.report_date, + service_ids=input.service_ids, + project_ids=input.project_ids, + notes=input.notes + ) + result = command.execute() + return { + 'report': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'report': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the report" + } + + +class DeleteReportMutation(graphene.Mutation): + """Mutation to delete a report.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + command = DeleteReportCommand(report_id=id) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the report" + } + + +class GetTeamMemberActivityMutation(graphene.Mutation): + """Mutation to get activity summary for a team member.""" + class Arguments: + team_member_id = graphene.ID(required=True) + start_date = graphene.String() + end_date = graphene.String() + + activity = JSONString() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, team_member_id, start_date=None, end_date=None): + try: + command = GetTeamMemberActivityCommand( + team_member_id=team_member_id, + start_date=start_date, + end_date=end_date + ) + result = command.execute() + return { + 'activity': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'activity': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving team member activity" + } + + +class GetTeamSummaryMutation(graphene.Mutation): + """Mutation to get activity summary for all team members.""" + class Arguments: + start_date = graphene.String() + end_date = graphene.String() + + summary = JSONString() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, start_date=None, end_date=None): + try: + command = GetTeamSummaryCommand( + start_date=start_date, + end_date=end_date + ) + result = command.execute() + return { + 'summary': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'summary': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving team summary" + } + +class GetTeamMemberReportsMutation(graphene.Mutation): + """Mutation to get reports for a team member.""" + class Arguments: + team_member_id = graphene.ID(required=True) + start_date = graphene.String() + end_date = graphene.String() + + reports = List(ReportType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, team_member_id, start_date=None, end_date=None): + try: + command = GetTeamMemberReportsCommand( + team_member_id=team_member_id, + start_date=start_date, + end_date=end_date + ) + result = command.execute() + return { + 'reports': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'reports': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving team member reports" + } diff --git a/backend/graphql_api/mutations/revenues/__init__.py b/backend/graphql_api/mutations/revenues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/revenues/revenues.py b/backend/graphql_api/mutations/revenues/revenues.py new file mode 100644 index 0000000..5f1376f --- /dev/null +++ b/backend/graphql_api/mutations/revenues/revenues.py @@ -0,0 +1,265 @@ +import graphene +from graphene import Field, Boolean, List, String, Float + +from backend.core.commands import ( + CreateRevenueCommand, + UpdateRevenueCommand, + DeleteRevenueCommand, + EndRevenueCommand, + GetRevenueByDateRangeCommand, + CalculateTotalRevenueCommand, + GetActiveRevenuesCommand +) +from backend.core.repositories import RevenueRepository, AccountRepository +from backend.graphql_api.types import RevenueType +from backend.graphql_api.inputs import RevenueCreateInput, RevenueUpdateInput + + +class CreateRevenueMutation(graphene.Mutation): + """Mutation to create a new revenue record.""" + class Arguments: + input = RevenueCreateInput(required=True) + + revenue = Field(RevenueType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + revenue_repo = RevenueRepository() + account_repo = AccountRepository() + command = CreateRevenueCommand( + revenue_repo=revenue_repo, + account_repo=account_repo, + account_id=input.account_id, + amount=input.amount, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'revenue': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'revenue': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the revenue record" + } + + +class UpdateRevenueMutation(graphene.Mutation): + """Mutation to update an existing revenue record.""" + class Arguments: + input = RevenueUpdateInput(required=True) + + revenue = Field(RevenueType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + revenue_repo = RevenueRepository() + account_repo = AccountRepository() + command = UpdateRevenueCommand( + revenue_repo=revenue_repo, + account_repo=account_repo, + revenue_id=input.id, + amount=input.amount, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'revenue': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'revenue': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the revenue record" + } + + +class DeleteRevenueMutation(graphene.Mutation): + """Mutation to delete a revenue record.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + revenue_repo = RevenueRepository() + command = DeleteRevenueCommand( + revenue_repo=revenue_repo, + revenue_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the revenue record" + } + + +class EndRevenueMutation(graphene.Mutation): + """Mutation to end a revenue record.""" + class Arguments: + id = graphene.ID(required=True) + end_date = graphene.String() + + revenue = Field(RevenueType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, end_date=None): + try: + revenue_repo = RevenueRepository() + command = EndRevenueCommand( + revenue_repo=revenue_repo, + revenue_id=id, + end_date=end_date + ) + result = command.execute() + return { + 'revenue': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'revenue': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while ending the revenue record" + } + + +class CalculateTotalRevenueMutation(graphene.Mutation): + """Mutation to calculate total revenue.""" + class Arguments: + account_id = graphene.ID() + start_date = graphene.String() + end_date = graphene.String() + + total_revenue = Float() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, account_id=None, start_date=None, end_date=None): + try: + revenue_repo = RevenueRepository() + command = CalculateTotalRevenueCommand( + revenue_repo=revenue_repo, + start_date=start_date, + end_date=end_date, + account_id=account_id + ) + result = command.execute() + return { + 'total_revenue': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'total_revenue': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while calculating total revenue" + } + + +class GetRevenueByDateRangeMutation(graphene.Mutation): + """Mutation to get revenues within a date range.""" + class Arguments: + start_date = graphene.String(required=True) + end_date = graphene.String(required=True) + + revenues = List(RevenueType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, start_date, end_date): + try: + revenue_repo = RevenueRepository() + command = GetRevenueByDateRangeCommand( + revenue_repo=revenue_repo, + start_date=start_date, + end_date=end_date + ) + result = command.execute() + return { + 'revenues': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'revenues': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving revenues by date range" + } + + +class GetActiveRevenuesMutation(graphene.Mutation): + """Mutation to get all active revenue records.""" + class Arguments: + pass + + revenues = List(RevenueType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info): + try: + revenue_repo = RevenueRepository() + command = GetActiveRevenuesCommand(revenue_repo=revenue_repo) + result = command.execute() + return { + 'revenues': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'revenues': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving active revenues" + } diff --git a/backend/graphql_api/mutations/schedules/__init__.py b/backend/graphql_api/mutations/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/mutations/schedules/schedules.py b/backend/graphql_api/mutations/schedules/schedules.py new file mode 100644 index 0000000..3bd0cc5 --- /dev/null +++ b/backend/graphql_api/mutations/schedules/schedules.py @@ -0,0 +1,317 @@ +import graphene +from graphene import Field, Boolean, List, String, JSONString + +from backend.core.commands import ( + CreateScheduleCommand, + UpdateScheduleCommand, + DeleteScheduleCommand, + EndScheduleCommand, + GetActiveSchedulesCommand, + GenerateServicesCommand, + GetScheduleByAccountCommand, + SearchSchedulesCommand +) +from backend.core.repositories import ScheduleRepository, AccountRepository +from backend.graphql_api.types import ScheduleType +from backend.graphql_api.inputs import ScheduleCreateInput, ScheduleUpdateInput + + +class CreateScheduleMutation(graphene.Mutation): + """Mutation to create a new schedule.""" + class Arguments: + input = ScheduleCreateInput(required=True) + + schedule = Field(ScheduleType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + schedule_repo = ScheduleRepository() + account_repo = AccountRepository() + command = CreateScheduleCommand( + schedule_repo=schedule_repo, + account_repo=account_repo, + account_id=input.account_id, + start_date=input.start_date, + monday_service=input.monday_service, + tuesday_service=input.tuesday_service, + wednesday_service=input.wednesday_service, + thursday_service=input.thursday_service, + friday_service=input.friday_service, + saturday_service=input.saturday_service, + sunday_service=input.sunday_service, + weekend_service=input.weekend_service, + schedule_exception=input.schedule_exception, + end_date=input.end_date + ) + result = command.execute() + return { + 'schedule': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'schedule': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the schedule" + } + + +class UpdateScheduleMutation(graphene.Mutation): + """Mutation to update an existing schedule.""" + class Arguments: + input = ScheduleUpdateInput(required=True) + + schedule = Field(ScheduleType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + schedule_repo = ScheduleRepository() + account_repo = AccountRepository() + command = UpdateScheduleCommand( + schedule_repo=schedule_repo, + account_repo=account_repo, + schedule_id=input.id, + monday_service=input.monday_service, + tuesday_service=input.tuesday_service, + wednesday_service=input.wednesday_service, + thursday_service=input.thursday_service, + friday_service=input.friday_service, + saturday_service=input.saturday_service, + sunday_service=input.sunday_service, + weekend_service=input.weekend_service, + schedule_exception=input.schedule_exception, + start_date=input.start_date, + end_date=input.end_date + ) + result = command.execute() + return { + 'schedule': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'schedule': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the schedule" + } + + +class DeleteScheduleMutation(graphene.Mutation): + """Mutation to delete a schedule.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + schedule_repo = ScheduleRepository() + command = DeleteScheduleCommand( + schedule_repo=schedule_repo, + schedule_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the schedule" + } + + +class EndScheduleMutation(graphene.Mutation): + """Mutation to end a schedule.""" + class Arguments: + id = graphene.ID(required=True) + end_date = graphene.String() + + schedule = Field(ScheduleType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, end_date=None): + try: + schedule_repo = ScheduleRepository() + command = EndScheduleCommand( + schedule_repo=schedule_repo, + schedule_id=id, + end_date=end_date + ) + result = command.execute() + return { + 'schedule': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'schedule': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while ending the schedule" + } + + +class GenerateServicesMutation(graphene.Mutation): + """Mutation to generate services from a schedule.""" + class Arguments: + schedule_id = graphene.ID(required=True) + start_date = graphene.String(required=True) + end_date = graphene.String(required=True) + + services = List(JSONString) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, schedule_id, start_date, end_date): + try: + schedule_repo = ScheduleRepository() + command = GenerateServicesCommand( + schedule_repo=schedule_repo, + schedule_id=schedule_id, + start_date=start_date, + end_date=end_date + ) + result = command.execute() + return { + 'services': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'services': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while generating services" + } + + +class GetActiveSchedulesMutation(graphene.Mutation): + """Mutation to get active schedules.""" + class Arguments: + account_id = graphene.ID() + + schedules = List(ScheduleType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, account_id=None): + try: + schedule_repo = ScheduleRepository() + command = GetActiveSchedulesCommand( + schedule_repo=schedule_repo, + account_id=account_id + ) + result = command.execute() + return { + 'schedules': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'schedules': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving active schedules" + } + + +class GetScheduleByAccountMutation(graphene.Mutation): + """Mutation to get schedules for an account.""" + class Arguments: + account_id = graphene.ID(required=True) + + schedules = List(ScheduleType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, account_id): + try: + schedule_repo = ScheduleRepository() + command = GetScheduleByAccountCommand( + schedule_repo=schedule_repo, + account_id=account_id + ) + result = command.execute() + return { + 'schedules': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'schedules': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving account schedules" + } + + +class SearchSchedulesMutation(graphene.Mutation): + """Mutation to search schedules.""" + class Arguments: + search_term = graphene.String(required=True) + + schedules = List(ScheduleType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, search_term): + try: + schedule_repo = ScheduleRepository() + command = SearchSchedulesCommand( + schedule_repo=schedule_repo, + search_term=search_term + ) + result = command.execute() + return { + 'schedules': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'schedules': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while searching schedules" + } diff --git a/backend/graphql_api/mutations/services/__init__.py b/backend/graphql_api/mutations/services/__init__.py new file mode 100644 index 0000000..50ee14f --- /dev/null +++ b/backend/graphql_api/mutations/services/__init__.py @@ -0,0 +1,21 @@ +from backend.graphql_api.mutations.services.services import ( + CreateServiceMutation, + UpdateServiceMutation, + DeleteServiceMutation, + CompleteServiceMutation, + CancelServiceMutation, + AssignTeamMembersMutation, + GetServicesByDateRangeMutation, + BulkScheduleServicesMutation +) + +__all__ = [ + 'CreateServiceMutation', + 'UpdateServiceMutation', + 'DeleteServiceMutation', + 'CompleteServiceMutation', + 'CancelServiceMutation', + 'AssignTeamMembersMutation', + 'GetServicesByDateRangeMutation', + 'BulkScheduleServicesMutation' +] diff --git a/backend/graphql_api/mutations/services/services.py b/backend/graphql_api/mutations/services/services.py new file mode 100644 index 0000000..2f715fd --- /dev/null +++ b/backend/graphql_api/mutations/services/services.py @@ -0,0 +1,328 @@ +import graphene +import json +from graphene import Field, Boolean, List, String, JSONString, Int + +from backend.core.commands import ( + CreateServiceCommand, + UpdateServiceCommand, + DeleteServiceCommand, + CompleteServiceCommand, + CancelServiceCommand, + AssignTeamMembersCommand, + GetServicesByDateRangeCommand, + BulkScheduleServicesCommand +) +from backend.core.repositories import ServiceRepository, AccountRepository, ProfileRepository, ScheduleRepository +from backend.graphql_api.types import ServiceType +from backend.graphql_api.inputs import ServiceCreateInput, ServiceUpdateInput + + +class CreateServiceMutation(graphene.Mutation): + """Mutation to create a new service.""" + class Arguments: + input = ServiceCreateInput(required=True) + + service = Field(ServiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + service_repo = ServiceRepository() + account_repo = AccountRepository() + profile_repo = ProfileRepository() + command = CreateServiceCommand( + service_repo=service_repo, + account_repo=account_repo, + profile_repo=profile_repo, + account_id=input.account_id, + date=input.date, + status=input.status, + team_member_ids=input.team_member_ids, + notes=input.notes, + deadline_start=input.deadline_start, + deadline_end=input.deadline_end + ) + result = command.execute() + return { + 'service': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'service': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while creating the service" + } + + +class UpdateServiceMutation(graphene.Mutation): + """Mutation to update an existing service.""" + class Arguments: + input = ServiceUpdateInput(required=True) + + service = Field(ServiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, input): + try: + service_repo = ServiceRepository() + account_repo = AccountRepository() + profile_repo = ProfileRepository() + command = UpdateServiceCommand( + service_repo=service_repo, + account_repo=account_repo, + profile_repo=profile_repo, + service_id=input.id, + status=input.status, + date=input.date, + team_member_ids=input.team_member_ids, + notes=input.notes, + deadline_start=input.deadline_start, + deadline_end=input.deadline_end + ) + result = command.execute() + return { + 'service': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'service': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while updating the service" + } + + +class DeleteServiceMutation(graphene.Mutation): + """Mutation to delete a service.""" + class Arguments: + id = graphene.ID(required=True) + + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id): + try: + service_repo = ServiceRepository() + command = DeleteServiceCommand( + service_repo=service_repo, + service_id=id + ) + result = command.execute() + return { + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while deleting the service" + } + + +class CompleteServiceMutation(graphene.Mutation): + """Mutation to mark a service as complete.""" + class Arguments: + id = graphene.ID(required=True) + completion_notes = graphene.String() + + service = Field(ServiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, completion_notes=None): + try: + service_repo = ServiceRepository() + command = CompleteServiceCommand( + service_repo=service_repo, + service_id=id, + completion_notes=completion_notes + ) + result = command.execute() + return { + 'service': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'service': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while completing the service" + } + + +class CancelServiceMutation(graphene.Mutation): + """Mutation to cancel a service.""" + class Arguments: + id = graphene.ID(required=True) + cancellation_reason = graphene.String() + + service = Field(ServiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, id, cancellation_reason=None): + try: + service_repo = ServiceRepository() + command = CancelServiceCommand( + service_repo=service_repo, + service_id=id, + cancellation_reason=cancellation_reason + ) + result = command.execute() + return { + 'service': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'service': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while cancelling the service" + } + + +class AssignTeamMembersMutation(graphene.Mutation): + """Mutation to assign team members to a service.""" + class Arguments: + service_id = graphene.ID(required=True) + team_member_ids = List(graphene.ID, required=True) + + service = Field(ServiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, service_id, team_member_ids): + try: + service_repo = ServiceRepository() + profile_repo = ProfileRepository() + command = AssignTeamMembersCommand( + service_repo=service_repo, + profile_repo=profile_repo, + service_id=service_id, + team_member_ids=team_member_ids + ) + result = command.execute() + return { + 'service': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'service': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while assigning team members" + } + + +class GetServicesByDateRangeMutation(graphene.Mutation): + """Mutation to get services within a date range.""" + class Arguments: + start_date = graphene.String(required=True) + end_date = graphene.String(required=True) + account_id = graphene.ID() + team_member_id = graphene.ID() + + services = List(ServiceType) + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, start_date, end_date, account_id=None, team_member_id=None): + try: + service_repo = ServiceRepository() + command = GetServicesByDateRangeCommand( + service_repo=service_repo, + start_date=start_date, + end_date=end_date, + account_id=account_id, + team_member_id=team_member_id + ) + result = command.execute() + return { + 'services': result.data, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'services': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while retrieving services" + } + + +class BulkScheduleServicesMutation(graphene.Mutation): + """Mutation to bulk schedule services for multiple accounts.""" + class Arguments: + account_ids = List(graphene.ID, required=True) + year = Int(required=True) + month = Int(required=True) + + results = JSONString() + success = Boolean() + errors = List(String) + message = String() + + @staticmethod + def mutate(root, info, account_ids, year, month): + try: + account_repo = AccountRepository() + schedule_repo = ScheduleRepository() + command = BulkScheduleServicesCommand( + account_repo=account_repo, + schedule_repo=schedule_repo, + account_ids=account_ids, + year=year, + month=month + ) + result = command.execute() + # Convert result.data to JSON string to ensure it's serializable + serialized_results = json.dumps(result.data, default=str) + return { + 'results': serialized_results, + 'success': result.success, + 'errors': result.errors, + 'message': result.message + } + except Exception as e: + return { + 'results': None, + 'success': False, + 'errors': [str(e)], + 'message': "An error occurred while bulk scheduling services" + } diff --git a/backend/graphql_api/queries/__init__.py b/backend/graphql_api/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/accounts/__init__.py b/backend/graphql_api/queries/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/accounts/accounts.py b/backend/graphql_api/queries/accounts/accounts.py new file mode 100644 index 0000000..82fe6b2 --- /dev/null +++ b/backend/graphql_api/queries/accounts/accounts.py @@ -0,0 +1,52 @@ +import graphene +from graphene import List + +from backend.core.repositories import AccountRepository +from backend.graphql_api.types import AccountType +from backend.graphql_api.inputs import AccountFilterInput + + +class AccountQueries(graphene.ObjectType): + """Account query operations""" + + account = graphene.Field( + AccountType, + id=graphene.ID(required=True) + ) + + accounts = graphene.Field( + List(AccountType), + filter=AccountFilterInput() + ) + + def resolve_account(self, info, id): + return AccountRepository.get_by_id(id) + + def resolve_accounts(self, info, filter=None): + if not filter: + return AccountRepository.get_all() + + # Apply filters based on input + if filter.customer_id: + return AccountRepository.get_by_customer(filter.customer_id) + + if filter.name: + return AccountRepository.search(filter.name) + + if filter.is_active is not None: + return AccountRepository.get_active() if filter.is_active else AccountRepository.get_inactive() + + if filter.has_services is not None: + return AccountRepository.get_with_services() if filter.has_services else AccountRepository.get_without_services() + + if filter.has_projects is not None: + return AccountRepository.get_with_projects() if filter.has_projects else AccountRepository.get_without_projects() + + return AccountRepository.filter_accounts( + city=filter.city, + state=filter.state, + zip_code=filter.zip_code, + start_date_after=filter.start_date_after, + start_date_before=filter.start_date_before, + contact_email=filter.contact_email + ) diff --git a/backend/graphql_api/queries/customers/__init__.py b/backend/graphql_api/queries/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/customers/customers.py b/backend/graphql_api/queries/customers/customers.py new file mode 100644 index 0000000..7265d76 --- /dev/null +++ b/backend/graphql_api/queries/customers/customers.py @@ -0,0 +1,38 @@ +import graphene +from graphene import List + +from backend.core.repositories import CustomerRepository +from backend.graphql_api.types import CustomerType +from backend.graphql_api.inputs import CustomerFilterInput + + +class CustomerQueries(graphene.ObjectType): + """Customer query operations""" + + customer = graphene.Field( + CustomerType, + id=graphene.ID(required=True) + ) + + customers = graphene.Field( + List(CustomerType), + filter=CustomerFilterInput() + ) + + def resolve_customer(self, info, id): + return CustomerRepository.get_by_id(id) + + def resolve_customers(self, info, filter=None): + if not filter: + return CustomerRepository.get_all() + + if filter.is_active is not None: + return CustomerRepository.get_active() if filter.is_active else CustomerRepository.get_inactive() + + return CustomerRepository.filter_customers( + name=filter.name, + city=filter.city, + state=filter.state, + start_date=filter.start_date, + end_date=filter.end_date + ) diff --git a/backend/graphql_api/queries/invoices/__init__.py b/backend/graphql_api/queries/invoices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/invoices/invoices.py b/backend/graphql_api/queries/invoices/invoices.py new file mode 100644 index 0000000..0389fb0 --- /dev/null +++ b/backend/graphql_api/queries/invoices/invoices.py @@ -0,0 +1,42 @@ +import graphene +from graphene import List + +from backend.core.repositories import InvoiceRepository +from backend.graphql_api.types import InvoiceType +from backend.graphql_api.inputs import InvoiceFilterInput + + +class InvoiceQueries(graphene.ObjectType): + """Invoice query operations""" + + invoice = graphene.Field( + InvoiceType, + id=graphene.ID(required=True) + ) + + invoices = graphene.Field( + List(InvoiceType), + filter=InvoiceFilterInput() + ) + + def resolve_invoice(self, info, id): + return InvoiceRepository.get_by_id(id) + + def resolve_invoices(self, info, filter=None): + if not filter: + return InvoiceRepository.get_all() + + if filter.customer_id: + return InvoiceRepository.get_by_customer(filter.customer_id) + + if filter.status: + return InvoiceRepository.get_by_status(filter.status) + + return InvoiceRepository.filter_invoices( + customer_id=filter.customer_id, + status=filter.status, + date_from=filter.start_date, + date_to=filter.end_date, + account_id=None, # account_id is not in InvoiceFilterInput + project_id=None # project_id is not in InvoiceFilterInput + ) diff --git a/backend/graphql_api/queries/labor/__init__.py b/backend/graphql_api/queries/labor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/labor/labor.py b/backend/graphql_api/queries/labor/labor.py new file mode 100644 index 0000000..738161d --- /dev/null +++ b/backend/graphql_api/queries/labor/labor.py @@ -0,0 +1,38 @@ +import graphene +from graphene import List + +from backend.core.repositories import LaborRepository +from backend.graphql_api.types import LaborType +from backend.graphql_api.inputs import LaborFilterInput + + +class LaborQueries(graphene.ObjectType): + """Labor query operations""" + + labor = graphene.Field( + LaborType, + id=graphene.ID(required=True) + ) + + labors = graphene.Field( + List(LaborType), + filter=LaborFilterInput() + ) + + def resolve_labor(self, info, id): + return LaborRepository.get_by_id(id) + + def resolve_labors(self, info, filter=None): + if not filter: + return LaborRepository.get_all() + + if filter.account_id: + return LaborRepository.get_by_account(filter.account_id) + + if filter.is_active is not None: + return LaborRepository.get_active() if filter.is_active else LaborRepository.get_inactive() + + return LaborRepository.get_by_date_range( + start_date=filter.start_date, + end_date=filter.end_date + ) diff --git a/backend/graphql_api/queries/profiles/__init__.py b/backend/graphql_api/queries/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/profiles/profiles.py b/backend/graphql_api/queries/profiles/profiles.py new file mode 100644 index 0000000..c7df4f2 --- /dev/null +++ b/backend/graphql_api/queries/profiles/profiles.py @@ -0,0 +1,54 @@ +import graphene +from graphene import List + +from backend.core.repositories import ProfileRepository +from backend.graphql_api.types import ProfileType +from backend.graphql_api.inputs import ProfileSearchInput + + +class ProfileQueries(graphene.ObjectType): + """Profile query operations""" + + profile = graphene.Field( + ProfileType, + id=graphene.ID(required=True) + ) + + profile_detail = graphene.Field( + ProfileType, + id=graphene.ID(required=True) + ) + + my_profile = graphene.Field(ProfileType) + + profiles = graphene.Field( + List(ProfileType), + filter=ProfileSearchInput() + ) + + def resolve_profile(self, info, id): + return ProfileRepository.get_by_id(id) + + def resolve_profile_detail(self, info, id): + return ProfileRepository.get_by_id(id) + + def resolve_my_profile(self, info): + if info.context.user.is_authenticated: + return ProfileRepository.get_by_user(info.context.user) + return None + + def resolve_profiles(self, info, filter=None): + if not filter: + return ProfileRepository.get_all() + + queryset = ProfileRepository.get_all() + + if filter.role: + queryset = queryset.filter(role=filter.role) + + if filter.search_term: + queryset = ProfileRepository.search(filter.search_term) + if filter.role: + queryset = queryset.filter(role=filter.role) + + return queryset diff --git a/backend/graphql_api/queries/projects/__init__.py b/backend/graphql_api/queries/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/projects/projects.py b/backend/graphql_api/queries/projects/projects.py new file mode 100644 index 0000000..55050a3 --- /dev/null +++ b/backend/graphql_api/queries/projects/projects.py @@ -0,0 +1,48 @@ +import graphene +from graphene import List + +from backend.core.repositories import ProjectRepository +from backend.graphql_api.types import ProjectType +from backend.graphql_api.inputs import ProjectFilterInput + + +class ProjectQueries(graphene.ObjectType): + """Project query operations""" + + project = graphene.Field( + ProjectType, + id=graphene.ID(required=True) + ) + + projects = graphene.Field( + List(ProjectType), + filter=ProjectFilterInput() + ) + + def resolve_project(self, info, id): + return ProjectRepository.get_by_id(id) + + def resolve_projects(self, info, filter=None): + if not filter: + return ProjectRepository.get_all() + + if filter.customer_id: + return ProjectRepository.get_by_customer(filter.customer_id) + + if filter.account_id: + return ProjectRepository.get_by_account(filter.account_id) + + if filter.team_member_id: + return ProjectRepository.get_by_team_member(filter.team_member_id) + + if filter.status: + return ProjectRepository.get_by_status(filter.status) + + return ProjectRepository.filter_projects( + customer_id=filter.customer_id, + account_id=filter.account_id, + status=filter.status, + date_from=filter.start_date, + date_to=filter.end_date, + team_member_id=filter.team_member_id + ) diff --git a/backend/graphql_api/queries/reports/__init__.py b/backend/graphql_api/queries/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/reports/reports.py b/backend/graphql_api/queries/reports/reports.py new file mode 100644 index 0000000..f4e300a --- /dev/null +++ b/backend/graphql_api/queries/reports/reports.py @@ -0,0 +1,35 @@ +import graphene +from graphene import List + +from backend.core.repositories import ReportRepository +from backend.graphql_api.types import ReportType +from backend.graphql_api.inputs import ReportFilterInput + + +class ReportQueries(graphene.ObjectType): + """Report query operations""" + + report = graphene.Field( + ReportType, + id=graphene.ID(required=True) + ) + + reports = graphene.Field( + List(ReportType), + filter=ReportFilterInput() + ) + + def resolve_report(self, info, id): + return ReportRepository.get_with_all_related(id) + + def resolve_reports(self, info, filter=None): + if not filter: + return ReportRepository.get_all() + + if filter.team_member_id: + return ReportRepository.get_by_team_member(filter.team_member_id) + + return ReportRepository.get_by_date_range( + start_date=filter.start_date, + end_date=filter.end_date + ) diff --git a/backend/graphql_api/queries/revenues/__init__.py b/backend/graphql_api/queries/revenues/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/revenues/revenues.py b/backend/graphql_api/queries/revenues/revenues.py new file mode 100644 index 0000000..8061e2f --- /dev/null +++ b/backend/graphql_api/queries/revenues/revenues.py @@ -0,0 +1,38 @@ +import graphene +from graphene import List + +from backend.core.repositories import RevenueRepository +from backend.graphql_api.types import RevenueType +from backend.graphql_api.inputs import RevenueFilterInput + + +class RevenueQueries(graphene.ObjectType): + """Revenue query operations""" + + revenue = graphene.Field( + RevenueType, + id=graphene.ID(required=True) + ) + + revenues = graphene.Field( + List(RevenueType), + filter=RevenueFilterInput() + ) + + def resolve_revenue(self, info, id): + return RevenueRepository.get_by_id(id) + + def resolve_revenues(self, info, filter=None): + if not filter: + return RevenueRepository.get_all() + + if filter.account_id: + return RevenueRepository.get_by_account(filter.account_id) + + if filter.is_active is not None: + return RevenueRepository.get_active() if filter.is_active else RevenueRepository.get_inactive() + + return RevenueRepository.get_by_date_range( + start_date=filter.start_date, + end_date=filter.end_date + ) diff --git a/backend/graphql_api/queries/schedules/__init__.py b/backend/graphql_api/queries/schedules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/schedules/schedules.py b/backend/graphql_api/queries/schedules/schedules.py new file mode 100644 index 0000000..ea9c940 --- /dev/null +++ b/backend/graphql_api/queries/schedules/schedules.py @@ -0,0 +1,38 @@ +import graphene +from graphene import List + +from backend.core.repositories import ScheduleRepository +from backend.graphql_api.types import ScheduleType +from backend.graphql_api.inputs import ScheduleFilterInput + + +class ScheduleQueries(graphene.ObjectType): + """Schedule query operations""" + + schedule = graphene.Field( + ScheduleType, + id=graphene.ID(required=True) + ) + + schedules = graphene.Field( + List(ScheduleType), + filter=ScheduleFilterInput() + ) + + def resolve_schedule(self, info, id): + return ScheduleRepository.get_by_id(id) + + def resolve_schedules(self, info, filter=None): + if not filter: + return ScheduleRepository.get_all() + + if filter.account_id: + return ScheduleRepository.get_by_account(filter.account_id) + + if filter.is_active is not None: + return ScheduleRepository.get_active() if filter.is_active else ScheduleRepository.get_inactive() + + return ScheduleRepository.get_by_date_range( + start_date=filter.start_date, + end_date=filter.end_date + ) diff --git a/backend/graphql_api/queries/services/__init__.py b/backend/graphql_api/queries/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/graphql_api/queries/services/services.py b/backend/graphql_api/queries/services/services.py new file mode 100644 index 0000000..d074178 --- /dev/null +++ b/backend/graphql_api/queries/services/services.py @@ -0,0 +1,41 @@ +import graphene +from graphene import List + +from backend.core.repositories import ServiceRepository +from backend.graphql_api.types import ServiceType +from backend.graphql_api.inputs import ServiceFilterInput + + +class ServiceQueries(graphene.ObjectType): + """Service query operations""" + + service = graphene.Field( + ServiceType, + id=graphene.ID(required=True) + ) + + services = graphene.Field( + List(ServiceType), + filter=ServiceFilterInput() + ) + + def resolve_service(self, info, id): + return ServiceRepository.get_by_id(id) + + def resolve_services(self, info, filter=None): + if not filter: + return ServiceRepository.get_all() + + if filter.account_id: + return ServiceRepository.get_by_account(filter.account_id) + + if filter.status: + return ServiceRepository.get_by_status(filter.status) + + return ServiceRepository.filter_services( + account_id=filter.account_id, + status=filter.status, + date_from=filter.start_date, + date_to=filter.end_date, + team_member_id=filter.team_member_id + ) diff --git a/backend/graphql_api/schema.py b/backend/graphql_api/schema.py new file mode 100644 index 0000000..f1dd4dd --- /dev/null +++ b/backend/graphql_api/schema.py @@ -0,0 +1,167 @@ +import graphene + +from backend.graphql_api.queries.accounts.accounts import AccountQueries +from backend.graphql_api.queries.customers.customers import CustomerQueries +from backend.graphql_api.queries.invoices.invoices import InvoiceQueries +from backend.graphql_api.queries.labor.labor import LaborQueries +from backend.graphql_api.queries.profiles.profiles import ProfileQueries +from backend.graphql_api.queries.projects.projects import ProjectQueries +from backend.graphql_api.queries.reports.reports import ReportQueries +from backend.graphql_api.queries.revenues.revenues import RevenueQueries +from backend.graphql_api.queries.schedules.schedules import ScheduleQueries +from backend.graphql_api.queries.services.services import ServiceQueries + +from backend.graphql_api.mutations.auth.auth import ( + TokenAuthMutation, VerifyTokenMutation, + RefreshTokenMutation +) +from backend.graphql_api.mutations.accounts.accounts import ( + CreateAccountMutation, UpdateAccountMutation, + DeleteAccountMutation, MarkAccountInactiveMutation, + GetAccountRevenueMutation +) +from backend.graphql_api.mutations.customers.customers import ( + CreateCustomerMutation, UpdateCustomerMutation, + DeleteCustomerMutation, MarkCustomerInactiveMutation +) +from backend.graphql_api.mutations.invoices.invoices import ( + CreateInvoiceMutation, SendInvoiceMutation, + MarkInvoicePaidMutation, CancelInvoiceMutation +) +from backend.graphql_api.mutations.labor.labor import ( + CreateLaborMutation, UpdateLaborMutation, + DeleteLaborMutation, EndLaborMutation, + CalculateLaborCostMutation +) +from backend.graphql_api.mutations.profiles.profiles import ( + CreateProfileMutation, UpdateProfileMutation, + DeleteProfileMutation, SearchProfilesMutation +) +from backend.graphql_api.mutations.projects.projects import ( + CreateProjectMutation, UpdateProjectMutation, + DeleteProjectMutation +) +from backend.graphql_api.mutations.reports.reports import ( + CreateReportMutation, UpdateReportMutation, + DeleteReportMutation, GetTeamMemberActivityMutation, + GetTeamSummaryMutation, GetTeamMemberReportsMutation +) +from backend.graphql_api.mutations.revenues.revenues import ( + CreateRevenueMutation, UpdateRevenueMutation, + DeleteRevenueMutation, EndRevenueMutation, + CalculateTotalRevenueMutation, GetRevenueByDateRangeMutation, + GetActiveRevenuesMutation +) +from backend.graphql_api.mutations.schedules.schedules import ( + CreateScheduleMutation, UpdateScheduleMutation, + DeleteScheduleMutation, EndScheduleMutation, + GenerateServicesMutation, GetActiveSchedulesMutation, + GetScheduleByAccountMutation, SearchSchedulesMutation +) +from backend.graphql_api.mutations.services.services import ( + CreateServiceMutation, UpdateServiceMutation, + DeleteServiceMutation, CompleteServiceMutation, + CancelServiceMutation, AssignTeamMembersMutation, + GetServicesByDateRangeMutation, BulkScheduleServicesMutation +) + + +class Query( + AccountQueries, + CustomerQueries, + InvoiceQueries, + LaborQueries, + ProfileQueries, + ProjectQueries, + ReportQueries, + RevenueQueries, + ScheduleQueries, + ServiceQueries, + graphene.ObjectType +): + hello = graphene.String(default_value="Hi there!") + pass + + +class Mutation(graphene.ObjectType): + # Auth mutations + token_auth = TokenAuthMutation.Field() + verify_token = VerifyTokenMutation.Field() + refresh_token = RefreshTokenMutation.Field() + + # Account mutations + create_account = CreateAccountMutation.Field() + update_account = UpdateAccountMutation.Field() + delete_account = DeleteAccountMutation.Field() + mark_account_inactive = MarkAccountInactiveMutation.Field() + get_account_revenue = GetAccountRevenueMutation.Field() + + # Customer mutations + create_customer = CreateCustomerMutation.Field() + update_customer = UpdateCustomerMutation.Field() + delete_customer = DeleteCustomerMutation.Field() + mark_customer_inactive = MarkCustomerInactiveMutation.Field() + + # Invoice mutations + create_invoice = CreateInvoiceMutation.Field() + send_invoice = SendInvoiceMutation.Field() + mark_invoice_paid = MarkInvoicePaidMutation.Field() + cancel_invoice = CancelInvoiceMutation.Field() + + # Labor mutations + create_labor = CreateLaborMutation.Field() + update_labor = UpdateLaborMutation.Field() + delete_labor = DeleteLaborMutation.Field() + end_labor = EndLaborMutation.Field() + calculate_labor_cost = CalculateLaborCostMutation.Field() + + # Profile mutations + create_profile = CreateProfileMutation.Field() + update_profile = UpdateProfileMutation.Field() + delete_profile = DeleteProfileMutation.Field() + search_profiles = SearchProfilesMutation.Field() + + # Project mutations + create_project = CreateProjectMutation.Field() + update_project = UpdateProjectMutation.Field() + delete_project = DeleteProjectMutation.Field() + + # Report mutations + create_report = CreateReportMutation.Field() + update_report = UpdateReportMutation.Field() + delete_report = DeleteReportMutation.Field() + get_team_member_activity = GetTeamMemberActivityMutation.Field() + get_team_summary = GetTeamSummaryMutation.Field() + get_team_member_reports = GetTeamMemberReportsMutation.Field() + + # Revenue mutations + create_revenue = CreateRevenueMutation.Field() + update_revenue = UpdateRevenueMutation.Field() + delete_revenue = DeleteRevenueMutation.Field() + end_revenue = EndRevenueMutation.Field() + calculate_total_revenue = CalculateTotalRevenueMutation.Field() + get_revenue_by_date_range = GetRevenueByDateRangeMutation.Field() + get_active_revenues = GetActiveRevenuesMutation.Field() + + # Schedule mutations + create_schedule = CreateScheduleMutation.Field() + update_schedule = UpdateScheduleMutation.Field() + delete_schedule = DeleteScheduleMutation.Field() + end_schedule = EndScheduleMutation.Field() + generate_services = GenerateServicesMutation.Field() + get_active_schedules = GetActiveSchedulesMutation.Field() + get_schedule_by_account = GetScheduleByAccountMutation.Field() + search_schedules = SearchSchedulesMutation.Field() + + # Service mutations + create_service = CreateServiceMutation.Field() + update_service = UpdateServiceMutation.Field() + delete_service = DeleteServiceMutation.Field() + complete_service = CompleteServiceMutation.Field() + cancel_service = CancelServiceMutation.Field() + assign_team_members = AssignTeamMembersMutation.Field() + get_services_by_date_range = GetServicesByDateRangeMutation.Field() + bulk_schedule_services = BulkScheduleServicesMutation.Field() + + +schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/backend/graphql_api/types/__init__.py b/backend/graphql_api/types/__init__.py new file mode 100644 index 0000000..0fda296 --- /dev/null +++ b/backend/graphql_api/types/__init__.py @@ -0,0 +1,25 @@ +from .accounts import AccountType +from .customers import CustomerType +from .services import ServiceType +from .projects import ProjectType +from .invoices import InvoiceType +from .labor import LaborType +from .profiles import ProfileType +from .reports import ReportType +from .revenues import RevenueType +from .schedules import ScheduleType +from .punchlists import PunchlistType + +__all__ = [ + 'AccountType', + 'CustomerType', + 'ServiceType', + 'ProjectType', + 'InvoiceType', + 'LaborType', + 'ProfileType', + 'ReportType', + 'RevenueType', + 'ScheduleType', + 'PunchlistType', +] diff --git a/backend/graphql_api/types/accounts.py b/backend/graphql_api/types/accounts.py new file mode 100644 index 0000000..8bab9e2 --- /dev/null +++ b/backend/graphql_api/types/accounts.py @@ -0,0 +1,69 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Account +from backend.graphql_api.types.revenues import RevenueType +from backend.graphql_api.types.labor import LaborType + +class AccountType(DjangoObjectType): + """ + GraphQL type for the Account model. + """ + + class Meta: + model = Account + fields = ( + 'id', + 'customer', + 'name', + 'street_address', + 'city', + 'state', + 'zip_code', + 'primary_contact_first_name', + 'primary_contact_last_name', + 'primary_contact_phone', + 'primary_contact_email', + 'secondary_contact_first_name', + 'secondary_contact_last_name', + 'secondary_contact_phone', + 'secondary_contact_email', + 'start_date', + 'end_date', + 'created_at', + 'updated_at', + # Related fields + 'revenues', + 'schedules' + ) + + # Add computed properties from the model as GraphQL fields + is_active = graphene.Boolean(description="Whether the account is currently active") + primary_contact_full_name = graphene.String(description="Full name of the primary contact") + secondary_contact_full_name = graphene.String(description="Full name of the secondary contact") + address = graphene.String(description="Formatted full address") + current_revenue = graphene.Field(lambda: RevenueType, description="Current active revenue for this account") + current_labor = graphene.Field(lambda: LaborType, description="Current active labor for this account") + + def resolve_is_active(self, info): + """Resolve whether the account is active""" + return self.is_active + + def resolve_primary_contact_full_name(self, info): + """Resolve the primary contact's full name""" + return self.primary_contact_full_name + + def resolve_secondary_contact_full_name(self, info): + """Resolve the secondary contact's full name""" + return self.secondary_contact_full_name + + def resolve_address(self, info): + """Resolve the formatted address""" + return self.address + + def resolve_current_revenue(self, info): + """Resolve the current active revenue""" + return self.current_revenue() + + def resolve_current_labor(self, info): + """Resolve the current active labor""" + return self.current_labor() diff --git a/backend/graphql_api/types/customers.py b/backend/graphql_api/types/customers.py new file mode 100644 index 0000000..903fd32 --- /dev/null +++ b/backend/graphql_api/types/customers.py @@ -0,0 +1,75 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Customer + + +class CustomerType(DjangoObjectType): + """ + GraphQL type for the Customer model. + """ + + class Meta: + model = Customer + fields = ( + 'id', + 'name', + # Primary contact + 'primary_contact_first_name', + 'primary_contact_last_name', + 'primary_contact_phone', + 'primary_contact_email', + # Secondary contact + 'secondary_contact_first_name', + 'secondary_contact_last_name', + 'secondary_contact_phone', + 'secondary_contact_email', + # Billing information + 'billing_contact_first_name', + 'billing_contact_last_name', + 'billing_street_address', + 'billing_city', + 'billing_state', + 'billing_zip_code', + 'billing_email', + 'billing_terms', + # Dates + 'start_date', + 'end_date', + 'created_at', + 'updated_at', + # Related fields + 'accounts' + ) + + + # Add computed properties from the model as GraphQL fields + is_active = graphene.Boolean(description="Whether the customer is currently active") + primary_contact_full_name = graphene.String(description="Full name of the primary contact") + secondary_contact_full_name = graphene.String(description="Full name of the secondary contact") + billing_contact_full_name = graphene.String(description="Full name of the billing contact") + billing_address = graphene.String(description="Formatted billing address") + account_count = graphene.Int(description="Number of accounts associated with this customer") + + def resolve_is_active(self, _): + """Resolve whether the customer is active""" + return self.is_active + + def resolve_primary_contact_full_name(self, _): + """Resolve the primary contact's full name""" + return self.primary_contact_full_name + + def resolve_secondary_contact_full_name(self, _): + """Resolve the secondary contact's full name""" + return self.secondary_contact_full_name + + def resolve_billing_contact_full_name(self, _): + """Resolve the billing contact's full name""" + return self.billing_contact_full_name + + def resolve_billing_address(self, _): + """Resolve the formatted billing address""" + return self.billing_address + + def resolve_account_count(self, _): + """Resolve the number of accounts for this customer""" + return self.accounts.count() diff --git a/backend/graphql_api/types/invoices.py b/backend/graphql_api/types/invoices.py new file mode 100644 index 0000000..487d901 --- /dev/null +++ b/backend/graphql_api/types/invoices.py @@ -0,0 +1,57 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Invoice + + +class InvoiceType(DjangoObjectType): + """ + GraphQL type for the Invoice model. + """ + + class Meta: + model = Invoice + fields = ( + 'id', + 'customer', + 'date', + # Related items + 'accounts', + 'projects', + # Status and payment + 'status', + 'date_paid', + 'payment_type', + # Financial + 'total_amount', + # Timestamps + 'created_at', + 'updated_at', + 'sent_at' + ) + + # Add computed properties from the model as GraphQL fields + is_paid = graphene.Boolean(description="Whether the invoice has been paid") + is_overdue = graphene.Boolean(description="Whether the invoice is overdue") + days_outstanding = graphene.Int(description="Number of days since the invoice was sent") + status_display = graphene.String(description="Human-readable status") + payment_type_display = graphene.String(description="Human-readable payment type") + + def resolve_is_paid(self, _): + """Resolve whether the invoice is paid""" + return self.is_paid + + def resolve_is_overdue(self, _): + """Resolve whether the invoice is overdue""" + return self.is_overdue + + def resolve_days_outstanding(self, _): + """Resolve the number of days the invoice has been outstanding""" + return self.days_outstanding + + def resolve_status_display(self, _): + """Resolve the human-readable status""" + return self.get_status_display() + + def resolve_payment_type_display(self, _): + """Resolve the human-readable payment type""" + return self.get_payment_type_display() if self.payment_type else None diff --git a/backend/graphql_api/types/labor.py b/backend/graphql_api/types/labor.py new file mode 100644 index 0000000..1fd1748 --- /dev/null +++ b/backend/graphql_api/types/labor.py @@ -0,0 +1,33 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Labor + + +class LaborType(DjangoObjectType): + """ + GraphQL type for the Labor model. + """ + + class Meta: + model = Labor + fields = ( + 'id', + 'account', + 'amount', + 'start_date', + 'end_date', + 'created_at', + 'updated_at' + ) + + # Add computed properties from the model as GraphQL fields + is_active = graphene.Boolean(description="Whether the labor record is active") + duration_days = graphene.Int(description="Duration of the labor period in days") + + def resolve_is_active(self, info): + """Resolve whether the labor is active""" + return self.is_active + + def resolve_duration_days(self, info): + """Resolve the duration in days""" + return self.duration_days diff --git a/backend/graphql_api/types/profiles.py b/backend/graphql_api/types/profiles.py new file mode 100644 index 0000000..8d5be81 --- /dev/null +++ b/backend/graphql_api/types/profiles.py @@ -0,0 +1,62 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Profile +from django.contrib.auth.models import User + + +class UserType(DjangoObjectType): + """ + GraphQL type for the Django User model. + """ + + class Meta: + model = User + fields = ('id', 'username', 'email', 'first_name', 'last_name', 'is_active', 'date_joined', 'last_login') + + +class ProfileType(DjangoObjectType): + """ + GraphQL type for the Profile model. + """ + + class Meta: + model = Profile + fields = ( + 'id', + 'user', + 'first_name', + 'last_name', + 'primary_phone', + 'secondary_phone', + 'email', + 'role', + 'created_at', + 'updated_at' + ) + + # Add computed properties from the model as GraphQL fields + full_name = graphene.String(description="Full name of the user") + is_admin = graphene.Boolean(description="Whether the user has admin role") + is_team_leader = graphene.Boolean(description="Whether the user has team leader role") + is_team_member = graphene.Boolean(description="Whether the user has team member role") + role_display = graphene.String(description="Human-readable role") + + def resolve_full_name(self, _): + """Resolve the user's full name""" + return self.full_name + + def resolve_is_admin(self, _): + """Resolve whether the user is an admin""" + return self.is_admin + + def resolve_is_team_leader(self, _): + """Resolve whether the user is a team leader""" + return self.is_team_leader + + def resolve_is_team_member(self, _): + """Resolve whether the user is a team member""" + return self.is_team_member + + def resolve_role_display(self, _): + """Resolve the human-readable role""" + return dict(Profile.ROLE_CHOICES).get(self.role, self.role) diff --git a/backend/graphql_api/types/projects.py b/backend/graphql_api/types/projects.py new file mode 100644 index 0000000..fbd68ae --- /dev/null +++ b/backend/graphql_api/types/projects.py @@ -0,0 +1,60 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Project + + +class ProjectType(DjangoObjectType): + """ + GraphQL type for the Project model. + """ + + class Meta: + model = Project + fields = ( + 'id', + 'customer', + 'account', + 'date', + 'status', + 'team_members', + 'notes', + # Financial + 'labor', + 'amount', + # Timestamps + 'created_at', + 'updated_at', + 'completed_at' + ) + + # Add computed properties from the model as GraphQL fields + is_upcoming = graphene.Boolean(description="Whether the project is upcoming") + is_past_due = graphene.Boolean(description="Whether the project is past due") + team_member_names = graphene.List(graphene.String, description="List of team member names") + profit = graphene.Float(description="Project profit (amount - labor)") + profit_margin = graphene.Float(description="Project profit margin percentage") + status_display = graphene.String(description="Human-readable status") + + def resolve_is_upcoming(self, _): + """Resolve whether the project is upcoming""" + return self.is_upcoming + + def resolve_is_past_due(self, _): + """Resolve whether the project is past due""" + return self.is_past_due + + def resolve_team_member_names(self, _): + """Resolve the list of team member names""" + return self.team_member_names + + def resolve_profit(self, _): + """Resolve the project profit""" + return self.profit + + def resolve_profit_margin(self, _): + """Resolve the project profit margin""" + return self.profit_margin + + def resolve_status_display(self, _): + """Resolve the human-readable status""" + return self.get_status_display() diff --git a/backend/graphql_api/types/punchlists.py b/backend/graphql_api/types/punchlists.py new file mode 100644 index 0000000..799676e --- /dev/null +++ b/backend/graphql_api/types/punchlists.py @@ -0,0 +1,71 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Punchlist + + +class PunchlistType(DjangoObjectType): + """ + GraphQL type for the Punchlist model. + """ + + class Meta: + model = Punchlist + fields = ( + 'id', + 'project', + 'account', + 'date', + 'second_visit', + 'second_date', + # Front area section + 'front_ceiling', + 'front_vents', + 'front_fixtures', + 'front_counter', + # Main work area section + 'main_equipment', + 'main_equipment_disassemble', + 'main_equipment_reassemble', + 'main_equipment_alerts', + 'main_equipment_exterior', + 'main_walls', + 'main_fixtures', + 'main_ceiling', + 'main_vents', + 'main_floors', + # Equipment section + 'equip_primary', + 'equip_station_1', + 'equip_station_2', + 'equip_station_3', + 'equip_storage', + 'equip_prep', + 'equip_delivery', + 'equip_office', + 'equip_sinks', + 'equip_dispensers', + 'equip_other', + # Back area section + 'back_ceiling', + 'back_vents', + # End of visit section + 'end_trash', + 'end_clean', + 'end_secure', + # Notes + 'notes', + # Timestamps + 'created_at', + 'updated_at', + 'exported_at', + # Google API results + 'sheet_url', + 'pdf_url', + ) + + # Add any computed properties here if needed + is_exported = graphene.Boolean(description="Whether the punchlist has been exported") + + def resolve_is_exported(self, _): + """Resolve whether the punchlist has been exported""" + return self.exported_at is not None diff --git a/backend/graphql_api/types/reports.py b/backend/graphql_api/types/reports.py new file mode 100644 index 0000000..5f5b786 --- /dev/null +++ b/backend/graphql_api/types/reports.py @@ -0,0 +1,35 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Report + + +class ReportType(DjangoObjectType): + """ + GraphQL type for the Report model. + """ + + class Meta: + model = Report + fields = ( + 'id', + 'date', + 'team_member', + 'services', + 'projects', + 'notes', + # Timestamps + 'created_at', + 'updated_at' + ) + + # Add computed properties from the model as GraphQL fields + service_count = graphene.Int(description="Count of services in this report") + project_count = graphene.Int(description="Count of projects in this report") + + def resolve_service_count(self, _): + """Resolve the count of services in this report""" + return self.service_count + + def resolve_project_count(self, _): + """Resolve the count of projects in this report""" + return self.project_count diff --git a/backend/graphql_api/types/revenues.py b/backend/graphql_api/types/revenues.py new file mode 100644 index 0000000..452820f --- /dev/null +++ b/backend/graphql_api/types/revenues.py @@ -0,0 +1,33 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Revenue + + +class RevenueType(DjangoObjectType): + """ + GraphQL type for the Revenue model. + """ + + class Meta: + model = Revenue + fields = ( + 'id', + 'account', + 'amount', + 'start_date', + 'end_date', + 'created_at', + 'updated_at' + ) + + # Add computed properties from the model as GraphQL fields + is_active = graphene.Boolean(description="Whether the revenue record is active") + duration_days = graphene.Int(description="Duration of the revenue period in days") + + def resolve_is_active(self, info): + """Resolve whether the revenue is active""" + return self.is_active + + def resolve_duration_days(self, info): + """Resolve the duration in days""" + return self.duration_days diff --git a/backend/graphql_api/types/schedules.py b/backend/graphql_api/types/schedules.py new file mode 100644 index 0000000..96377fe --- /dev/null +++ b/backend/graphql_api/types/schedules.py @@ -0,0 +1,44 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Schedule + + +class ScheduleType(DjangoObjectType): + """ + GraphQL type for the Schedule model. + """ + + class Meta: + model = Schedule + fields = ( + 'id', + 'account', + # Service days + 'monday_service', + 'tuesday_service', + 'wednesday_service', + 'thursday_service', + 'friday_service', + 'saturday_service', + 'sunday_service', + 'weekend_service', + # Exceptions + 'schedule_exception', + # Dates + 'start_date', + 'end_date', + 'created_at', + 'updated_at' + ) + + # Add computed properties from the model as GraphQL fields + is_active = graphene.Boolean(description="Whether the schedule is currently active") + service_days = graphene.List(graphene.String, description="List of days with service scheduled") + + def resolve_is_active(self, _): + """Resolve whether the schedule is active""" + return self.is_active + + def resolve_service_days(self, _): + """Resolve the list of service days""" + return self.service_days diff --git a/backend/graphql_api/types/services.py b/backend/graphql_api/types/services.py new file mode 100644 index 0000000..2c53fb5 --- /dev/null +++ b/backend/graphql_api/types/services.py @@ -0,0 +1,54 @@ +import graphene +from graphene_django import DjangoObjectType +from backend.core.models import Service + + +class ServiceType(DjangoObjectType): + """ + GraphQL type for the Service model. + """ + + class Meta: + model = Service + fields = ( + 'id', + 'account', + 'date', + 'status', + 'team_members', + 'notes', + # Service window + 'deadline_start', + 'deadline_end', + # Timestamps + 'created_at', + 'updated_at', + 'completed_at' + ) + + # Add computed properties from the model as GraphQL fields + is_upcoming = graphene.Boolean(description="Whether the service is upcoming") + is_today = graphene.Boolean(description="Whether the service is scheduled for today") + is_past_due = graphene.Boolean(description="Whether the service is past due") + team_member_names = graphene.List(graphene.String, description="List of team member names") + status_display = graphene.String(description="Human-readable status") + + def resolve_is_upcoming(self, _): + """Resolve whether the service is upcoming""" + return self.is_upcoming + + def resolve_is_today(self, _): + """Resolve whether the service is scheduled for today""" + return self.is_today + + def resolve_is_past_due(self, _): + """Resolve whether the service is past due""" + return self.is_past_due + + def resolve_team_member_names(self, _): + """Resolve the list of team member names""" + return self.team_member_names + + def resolve_status_display(self, _): + """Resolve the human-readable status""" + return self.get_status_display() diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..d8a6a7c --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + # Add the project root to the Python path + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7eb9ad1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,20 @@ +asgiref==3.8.1 +certifi==2025.4.26 +Django==5.2.1 +django-cors-headers==4.7.0 +django-filter==25.1 +django-graphql-jwt==0.4.0 +graphene==3.4.3 +graphene-django==3.2.3 +graphql-core==3.2.6 +graphql-relay==3.2.0 +promise==2.3 +PyJWT==2.10.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 +sentry-sdk==2.27.0 +six==1.17.0 +sqlparse==0.5.3 +text-unidecode==1.3 +typing_extensions==4.13.2 +urllib3==2.4.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a6b0fe4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: '3.8' + +services: + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${DB_NAME:-nexus} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + - ./backend:/app + ports: + - "8000:8000" + environment: + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-config.settings.development} + - DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY} + - DEV_SECRET_KEY=${DEV_SECRET_KEY} + - DB_NAME=${DB_NAME:-nexus} + - DB_USER=${DB_USER:-postgres} + - DB_PASSWORD=${DB_PASSWORD:-postgres} + - DB_HOST=db + - DB_PORT=5432 + - REDIS_URL=redis://redis:6379/1 + depends_on: + - db + - redis + command: python manage.py runserver 0.0.0.0:8000 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "5173:5173" + environment: + - PUBLIC_GRAPHQL_URL=${PUBLIC_GRAPHQL_URL:-http://localhost:8000/graphql/} + depends_on: + - backend + command: npm run dev -- --host + +volumes: + postgres_data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..6562bcb --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,6 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..7ebb855 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4f80968 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:18-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci + +# Copy application code +COPY . . + +# Expose port +EXPOSE 5173 + +# Default command for development +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef07d32 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,36 @@ +import prettier from 'eslint-config-prettier'; +import js from '@eslint/js'; +import { includeIgnoreFile } from '@eslint/compat'; +import svelte from 'eslint-plugin-svelte'; +import globals from 'globals'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default ts.config( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { 'no-undef': 'off' } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..346c905 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5301 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "@urql/exchange-auth": "^2.2.1", + "@urql/svelte": "^4.2.3", + "date-fns": "^4.1.0", + "graphql": "^16.11.0", + "urql": "^4.2.2" + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.1.2.tgz", + "integrity": "sha512-N2NGsU5FLBhT8NZ+3l2YrzZSHITjNXNuDhC4iDiikv0IujaJ0Xc6xIxQZ/Ek3Cb+rgPjnLHYyJm11tInuJn+cw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.2.9.tgz", + "integrity": "sha512-gCdSY54n7k+driCadyMNv8JSPzYLeDVM/ikZRtvtROBpRdFSkS8W9A82MqsaY7lZuwL0wiapgD0NT1xT0hyJsA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", + "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.1.tgz", + "integrity": "sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.3.tgz", + "integrity": "sha512-pyltgilam1QPdn+Zd9gaCfOLcnjMEJ9gV+bTw6/r73INdvzf1ah9zLIJBm+kW7R6IUFIQ1YO+VqZtYxZNWFPEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", + "integrity": "sha512-tk5YCxJWIG81umIvNkSod2qK5KyQW19qcBF/B78n1bjtOON6gzKoVeSzAE8yHCZEDmqkHKkxplExA8KzdJLJpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", + "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.12.tgz", + "integrity": "sha512-0bp4Yb3jKIEcZWVcJC/L1xXp9zzJS4hDwfb4VITAkfT4OVdkspSHsx7YhqJDbb2hgLl6R9Vs7VQR+fqIVOxPUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.9.5" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.20.8", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.8.tgz", + "integrity": "sha512-ep9qTxL7WALhfm0kFecL3VHeuNew8IccbYGqv5TqL/KSqWRKzEgDG8blNlIu1CkLTTua/kHjI+f5T8eCmWIxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0", + "devalue": "^5.1.0", + "esm-env": "^1.2.2", + "import-meta-resolve": "^4.1.0", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.3 || ^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.0.3.tgz", + "integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.0", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.15", + "vitefu": "^1.0.4" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.5.tgz", + "integrity": "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.5.tgz", + "integrity": "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.5", + "@tailwindcss/oxide-darwin-arm64": "4.1.5", + "@tailwindcss/oxide-darwin-x64": "4.1.5", + "@tailwindcss/oxide-freebsd-x64": "4.1.5", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.5", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.5", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.5", + "@tailwindcss/oxide-linux-x64-musl": "4.1.5", + "@tailwindcss/oxide-wasm32-wasi": "4.1.5", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.5", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.5" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.5.tgz", + "integrity": "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.5.tgz", + "integrity": "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.5.tgz", + "integrity": "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.5.tgz", + "integrity": "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.5.tgz", + "integrity": "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.5.tgz", + "integrity": "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.5.tgz", + "integrity": "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.5.tgz", + "integrity": "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.5.tgz", + "integrity": "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.5.tgz", + "integrity": "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@emnapi/wasi-threads": "^1.0.2", + "@napi-rs/wasm-runtime": "^0.2.9", + "@tybys/wasm-util": "^0.9.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.5.tgz", + "integrity": "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.5.tgz", + "integrity": "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.5.tgz", + "integrity": "sha512-FE1stRoqdHSb7RxesMfCXE8icwI1W6zGE/512ae3ZDrpkQYTTYeSyUJPRCjZd8CwVAhpDUbi1YR8pcZioFJQ/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.5", + "@tailwindcss/oxide": "4.1.5", + "tailwindcss": "4.1.5" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", + "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/type-utils": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", + "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", + "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", + "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", + "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", + "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", + "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", + "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@urql/core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.1.1.tgz", + "integrity": "sha512-aGh024z5v2oINGD/In6rAtVKTm4VmQ2TxKQBAtk2ZSME5dunZFcjltw4p5ENQg+5CBhZ3FHMzl0Oa+rwqiWqlg==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.5", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-auth": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@urql/exchange-auth/-/exchange-auth-2.2.1.tgz", + "integrity": "sha512-n4xUxxjvY36GJh539sLaPFyvFzreADOv8EFQdTiXOCIRVoXXhWVVI7DjY/HNtLICfbg1+UlbvQ7sNkwaBouDHg==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.1", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0" + } + }, + "node_modules/@urql/svelte": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@urql/svelte/-/svelte-4.2.3.tgz", + "integrity": "sha512-v3eArfymhdjaM5VQFp3QZxq9veYPadmDfX7ueid/kD4DlRplIycPakJ2FrKigh46SXa5mWqJ3QWuWyRKVu61sw==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.1", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0", + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", + "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.26.0", + "@eslint/plugin-kit": "^0.2.8", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@modelcontextprotocol/sdk": "^1.8.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "zod": "^3.24.2" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-Qn1slddZHfqYiDO6IN8/iN3YL+VuHlgYjm30FT+hh0Jf/TX0jeZMTJXQMajFm5f6f6hURi+XO8P+NPYD+T4jkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "known-css-properties": "^0.35.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz", + "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/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, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/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, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", + "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphql": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", + "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.3.3.tgz", + "integrity": "sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sirv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", + "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.28.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.28.2.tgz", + "integrity": "sha512-FbWBxgWOpQfhKvoGJv/TFwzqb4EhJbwCD17dB0tEpQiw1XyUEKZJtgm4nA4xq3LLsMo7hu5UY/BOFmroAxKTMg==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^1.4.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.1.7.tgz", + "integrity": "sha512-1jX4BzXrQJhC/Jt3SqYf6Ntu//vmfc6VWp07JkRfK2nn+22yIblspVUo96gzMkg0Zov8lQicxhxsMzOctwcMQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.1.3.tgz", + "integrity": "sha512-DUc/z/vk+AFVoxGv54+BOBFqUrmUgNg2gSO2YqrE3OL6ro19/0azPmQj/4wN3s9RxuF5l7G0162q/Ddk4LJhZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte-eslint-parser/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz", + "integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.0.tgz", + "integrity": "sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.32.0", + "@typescript-eslint/parser": "8.32.0", + "@typescript-eslint/utils": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urql": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/urql/-/urql-4.2.2.tgz", + "integrity": "sha512-3GgqNa6iF7bC4hY/ImJKN4REQILcSU9VKcKL8gfELZM8mM5BnLH1BsCc8kBdnVGD1LIFOs4W3O2idNHhON1r0w==", + "license": "MIT", + "dependencies": { + "@urql/core": "^5.1.1", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^5.0.0", + "react": ">= 16.8.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vitefu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", + "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wonka": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", + "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..37f478a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,46 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.11", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6" + }, + "dependencies": { + "@urql/exchange-auth": "^2.2.1", + "@urql/svelte": "^4.2.3", + "date-fns": "^4.1.0", + "graphql": "^16.11.0", + "urql": "^4.2.2" + } +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..cd67023 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,3 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/forms'; +@plugin '@tailwindcss/typography'; diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..caaa945 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,22 @@ + + + + + + Nexus-V3 + %sveltekit.head% + + + +
%sveltekit.body%
+ + diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..de152d6 --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,21 @@ + + +
+
+

+ {page.status} +

+

+ {page.error?.message || "Something went wrong"} +

+ +
+
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..19d665b --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,49 @@ + +{#if isAuthenticated && currentPath !== '/'} + +
+{/if} +{@render children()} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..ce9cd22 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,61 @@ + + + +
+
+ {#if !showLogin} +
+

+ Welcome to Nexus +

+

+ Online Business & Team Management +

+ +
+ {:else} +
+
+

+ Welcome Back +

+

+ Sign in to your account +

+
+ + + +
+

+ Need help? Contact support +

+
+
+ {/if} +
+
diff --git a/frontend/src/routes/accounts/+page.svelte b/frontend/src/routes/accounts/+page.svelte new file mode 100644 index 0000000..1739029 --- /dev/null +++ b/frontend/src/routes/accounts/+page.svelte @@ -0,0 +1,150 @@ + + +{#if isAuthenticated} +
+
+
+
+
+
+

+ Accounts +

+ +
+ +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+
+ +
+ {#each filteredAccounts as account (account.id)} +
+
+

{account.name}

+
+ {account.customer.name} + • {account.primaryContactFirstName} {account.primaryContactLastName} + {#if !account.isActive} + • Inactive + {/if} +
+
+ +
+ {/each} +
+
+
+
+
+
+{/if} diff --git a/frontend/src/routes/accounts/[id]/+page.svelte b/frontend/src/routes/accounts/[id]/+page.svelte new file mode 100644 index 0000000..cd1395e --- /dev/null +++ b/frontend/src/routes/accounts/[id]/+page.svelte @@ -0,0 +1,152 @@ + + +
+
+
+
+
+ {#if $account.fetching} +
+
+

Loading account details...

+
+ {:else if $account.error} +
+

Error loading account: {$account.error.message}

+
+ {:else if $account.data?.account} +
+

+ {$account.data.account.name} +

+
+ + +
+
+ +
+ +
+

Account Information

+
+

+ {$account.data.account.name} +

+

+ Customer: + {$account.data.account.customer.name} + +

+

+ Since {format(parseISO($account.data.account.startDate), 'MM/dd/yyyy')} +

+ {#if $account.data.account.endDate} +

+ Ended {new Date($account.data.account.endDate).toLocaleDateString()} +

+ {/if} +
+
+ + +
+

Primary Contact

+
+

+ {$account.data.account.primaryContactFirstName} {$account.data.account.primaryContactLastName} +

+

+ {$account.data.account.primaryContactEmail} +

+

+ {$account.data.account.primaryContactPhone} +

+
+
+ + +
+

Address

+
+

+ {$account.data.account.streetAddress} +
+ {$account.data.account.city}, {$account.data.account.state} {$account.data.account.zipCode} +

+
+
+ + + {#if $account.data.account.secondaryContactFirstName || $account.data.account.secondaryContactLastName} +
+

+ Secondary Contact +

+
+

+ {$account.data.account.secondaryContactFirstName} {$account.data.account.secondaryContactLastName} +

+

+ {$account.data.account.secondaryContactEmail} +

+

+ {$account.data.account.secondaryContactPhone} +

+
+
+ {/if} +
+ + + + + + + + + + + + + + + {/if} +
+
+
+
+
diff --git a/frontend/src/routes/accounts/[id]/edit/+page.svelte b/frontend/src/routes/accounts/[id]/edit/+page.svelte new file mode 100644 index 0000000..78be022 --- /dev/null +++ b/frontend/src/routes/accounts/[id]/edit/+page.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {#if $account.data?.account} + + {/if} +
+
+
diff --git a/frontend/src/routes/accounts/create/+page.svelte b/frontend/src/routes/accounts/create/+page.svelte new file mode 100644 index 0000000..5c30339 --- /dev/null +++ b/frontend/src/routes/accounts/create/+page.svelte @@ -0,0 +1,26 @@ + + +
+
+
+ +
+
+
diff --git a/frontend/src/routes/customers/+page.svelte b/frontend/src/routes/customers/+page.svelte new file mode 100644 index 0000000..cc8ccf1 --- /dev/null +++ b/frontend/src/routes/customers/+page.svelte @@ -0,0 +1,125 @@ + + +{#if isAuthenticated} +
+
+
+
+
+
+

+ Customers +

+ +
+ +
+
+
+ +
+ +
+ +
+ + +
+
+ +
+ {#each filteredCustomers as customer (customer.id)} +
+
+

+ {customer.name} +

+
+ {customer.primaryContactFirstName} {customer.primaryContactLastName} + • {customer.primaryContactEmail} +
+
+ +
+ {/each} +
+
+
+
+
+
+{/if} diff --git a/frontend/src/routes/customers/[id]/+page.svelte b/frontend/src/routes/customers/[id]/+page.svelte new file mode 100644 index 0000000..6859ab3 --- /dev/null +++ b/frontend/src/routes/customers/[id]/+page.svelte @@ -0,0 +1,131 @@ + + +
+
+
+
+
+
+

+ {$customer.data?.customer.name} +

+
+ + +
+
+ + {#if $customer.data?.customer} +
+ +
+

Customer Information

+
+

+ {$customer.data.customer.name} +

+

+ Since {format(parseISO($customer.data.customer.startDate), 'MM/dd/yyyy')} +

+ {#if $customer.data.customer.endDate} +

+ Ended {$customer.data.customer.endDate} +

+ {/if} +
+
+ + +
+

Primary Contact

+
+

+ {$customer.data.customer.primaryContactFirstName} {$customer.data.customer.primaryContactLastName} +

+

+ {$customer.data.customer.primaryContactEmail} +

+

+ {$customer.data.customer.primaryContactPhone} +

+
+
+ + +
+

Billing Information

+
+

+ {$customer.data.customer.billingContactFirstName} {$customer.data.customer.billingContactLastName} +

+

+ {$customer.data.customer.billingEmail} +

+

+ {$customer.data.customer.billingStreetAddress} +
+ {$customer.data.customer.billingCity}, {$customer.data.customer.billingState} {$customer.data.customer.billingZipCode} +

+

+ Terms: {$customer.data.customer.billingTerms} +

+
+
+ + + {#if $customer.data.customer.secondaryContactFirstName || $customer.data.customer.secondaryContactLastName} +
+

+ Secondary Contact +

+
+

+ {$customer.data.customer.secondaryContactFirstName} {$customer.data.customer.secondaryContactLastName} +

+

+ {$customer.data.customer.secondaryContactEmail} +

+

+ {$customer.data.customer.secondaryContactPhone} +

+
+
+ {/if} +
+ + + {/if} +
+
+
+
+
diff --git a/frontend/src/routes/customers/[id]/edit/+page.svelte b/frontend/src/routes/customers/[id]/edit/+page.svelte new file mode 100644 index 0000000..c212660 --- /dev/null +++ b/frontend/src/routes/customers/[id]/edit/+page.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {#if $customer.data?.customer} + + {/if} +
+
+
diff --git a/frontend/src/routes/customers/create/+page.svelte b/frontend/src/routes/customers/create/+page.svelte new file mode 100644 index 0000000..65a4d8f --- /dev/null +++ b/frontend/src/routes/customers/create/+page.svelte @@ -0,0 +1,23 @@ + + +
+
+
+ +
+
+
diff --git a/frontend/src/routes/home/+page.svelte b/frontend/src/routes/home/+page.svelte new file mode 100644 index 0000000..db344c2 --- /dev/null +++ b/frontend/src/routes/home/+page.svelte @@ -0,0 +1,545 @@ + + +{#if isAuthenticated} +
+
+
+

+ Dashboard +

+ + +
+
+
+

+ Your Upcoming Services +

+ +
+
+ {#if showWorkWeekServices} + Showing services from {format(workWeekDates.start, 'MMM d')} to {format(workWeekDates.end, 'MMM d, yyyy')} + {:else} + Showing services from {format(monthDates.start, 'MMM d')} to {format(monthDates.end, 'MMM d, yyyy')} + {/if} +
+ + {#if $services.fetching} +
+
+

Loading services...

+
+ {:else if $services.error} +
+

Error loading services: {$services.error.message}

+
+ {:else if !$services.data?.services || $services.data.services.length === 0} +
+

No upcoming services assigned to you.

+
+ {:else} +
+ + + + + + + + + + + + {#each $services.data.services as service (service.id)} + + + + + + + + {/each} + +
AccountDateTime WindowStatusActions
+ {service.account.name} + + {formatDate(service.date)} + {#if service.isToday} + + Today + + {/if} + + {formatTime(service.deadlineStart)} - {formatTime(service.deadlineEnd)} + + + {service.statusDisplay || service.status} + + +
+ + + {#if canManageService} + + {/if} + + +
+
+
+ {/if} +
+
+ + +
+
+
+

+ Your Upcoming Projects +

+ +
+
+ {#if showWorkWeekProjects} + Showing projects from {format(workWeekDates.start, 'MMM d')} to {format(workWeekDates.end, 'MMM d, yyyy')} + {:else} + Showing projects from {format(monthDates.start, 'MMM d')} to {format(monthDates.end, 'MMM d, yyyy')} + {/if} +
+ + {#if $projects.fetching} +
+
+

Loading projects...

+
+ {:else if $projects.error} +
+

Error loading projects: {$projects.error.message}

+
+ {:else if !$projects.data?.projects || $projects.data.projects.length === 0} +
+

No upcoming projects assigned to you.

+
+ {:else} +
+ + + + + + + + + + + + {#each $projects.data.projects as project (project.id)} + + + + + + + + {/each} + +
CustomerAccountDateStatusActions
+ {project.customer.name} + + {project.account?.name || 'N/A'} + + {formatDate(project.date)} + + + {project.status} + + +
+ + + {#if canManageProject} + + {/if} + + +
+
+
+ {/if} +
+
+
+
+
+ + + {#if showServiceModal && selectedService} +
+
+
+

Complete Service

+ +
+

Service ID: {selectedService.id}

+

Account: {selectedService.account.name}

+

Date: {formatDate(selectedService.date)}

+

Time: {formatTime(selectedService.deadlineStart)} - {formatTime(selectedService.deadlineEnd)}

+ +
+ + +
+
+ +
+ + +
+
+
+
+ {/if} + + + {#if showProjectModal && selectedProject} +
+
+
+

Complete Project

+ +
+

Project ID: {selectedProject.id}

+

Customer: {selectedProject.customer.name}

+

Account: {selectedProject.account?.name || 'N/A'}

+

Date: {formatDate(selectedProject.date)}

+ +
+ + +
+
+ +
+ + +
+
+
+
+ {/if} +{/if} diff --git a/frontend/src/routes/invoices/+page.svelte b/frontend/src/routes/invoices/+page.svelte new file mode 100644 index 0000000..3fa469f --- /dev/null +++ b/frontend/src/routes/invoices/+page.svelte @@ -0,0 +1,148 @@ + + +
+
+
+
+
+
+

Invoices

+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {#if $invoices.fetching} +
+
+

Loading invoices...

+
+ {:else if $invoices.error} +
+

Error loading invoices: {$invoices.error.message}

+
+ {:else} + + {/if} +
+
+
+
diff --git a/frontend/src/routes/invoices/[id]/+page.svelte b/frontend/src/routes/invoices/[id]/+page.svelte new file mode 100644 index 0000000..7094d0c --- /dev/null +++ b/frontend/src/routes/invoices/[id]/+page.svelte @@ -0,0 +1,65 @@ + + +
+
+
+
+
+
+

+ Invoice Details +

+ +
+ + {#if $invoice.fetching} +
+
+

Loading invoice details...

+
+ {:else if $invoice.error} +
+

Error loading invoice: {$invoice.error.message}

+
+ {:else if $invoice.data?.invoice} + + {:else} +
+

Invoice not found.

+ +
+ {/if} +
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/routes/invoices/[id]/edit/+page.svelte b/frontend/src/routes/invoices/[id]/edit/+page.svelte new file mode 100644 index 0000000..8dd0474 --- /dev/null +++ b/frontend/src/routes/invoices/[id]/edit/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+
+ {#if $invoice.fetching} +
+
+

Loading invoice...

+
+ {:else if $invoice.error} +
+

Error loading invoice: {$invoice.error.message}

+
+ {:else if $invoice.data?.invoice} + + {:else} +
+

Invoice not found.

+ +
+ {/if} +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/invoices/create/+page.svelte b/frontend/src/routes/invoices/create/+page.svelte new file mode 100644 index 0000000..0f88e78 --- /dev/null +++ b/frontend/src/routes/invoices/create/+page.svelte @@ -0,0 +1,25 @@ + + +
+
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/labor/[id]/+page.svelte b/frontend/src/routes/labor/[id]/+page.svelte new file mode 100644 index 0000000..ce1ed1c --- /dev/null +++ b/frontend/src/routes/labor/[id]/+page.svelte @@ -0,0 +1,73 @@ + + +
+
+
+
+
+
+

+ Labor Details +

+ +
+ + {#if $labor.fetching} +
+
+

Loading labor details...

+
+ {:else if $labor.error} +
+

Error loading labor: {$labor.error.message}

+
+ {:else if $labor.data?.labor} + + {:else} +
+

Labor not found.

+ +
+ {/if} +
+
+
+
+
\ No newline at end of file diff --git a/frontend/src/routes/labor/[id]/edit/+page.svelte b/frontend/src/routes/labor/[id]/edit/+page.svelte new file mode 100644 index 0000000..d9241b5 --- /dev/null +++ b/frontend/src/routes/labor/[id]/edit/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+
+ {#if $labor.fetching} +
+
+

Loading labor...

+
+ {:else if $labor.error} +
+

Error loading labor: {$labor.error.message}

+
+ {:else if $labor.data?.labor} + + {:else} +
+

Labor not found.

+ +
+ {/if} +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/labor/create/+page.svelte b/frontend/src/routes/labor/create/+page.svelte new file mode 100644 index 0000000..9fc368d --- /dev/null +++ b/frontend/src/routes/labor/create/+page.svelte @@ -0,0 +1,25 @@ + + +
+
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/profiles/[id]/+page.svelte b/frontend/src/routes/profiles/[id]/+page.svelte new file mode 100644 index 0000000..9181a01 --- /dev/null +++ b/frontend/src/routes/profiles/[id]/+page.svelte @@ -0,0 +1,101 @@ + + +
+
+
+
+
+ {#if profileData().fetching} +
+
+
+ {:else if profileData().error} +
+

Error loading profile: {profileData().error?.message || 'Unknown error occurred'}

+
+ {:else if profileData().data?.profileDetail} +
+ +
+
+

+ {profileData().data.profileDetail.fullName || `${profileData().data.profileDetail.firstName} ${profileData().data.profileDetail.lastName}`} +

+

{profileData().data.profileDetail.roleDisplay}

+

{profileData().data.profileDetail.email}

+
+ + {#if isCurrentUser || authState.profile?.isAdmin} + + Edit Profile + + {/if} +
+ +
+
+

Contact Information

+
+

Primary Phone: {profileData().data.profileDetail.primaryPhone}

+ {#if profileData().data.profileDetail.secondaryPhone} +

Secondary Phone: {profileData().data.profileDetail.secondaryPhone}

+ {/if} +

Email: {profileData().data.profileDetail.email}

+
+
+ +
+

Account Information

+
+

Role: {profileData().data.profileDetail.roleDisplay}

+

Username: {profileData().data.profileDetail.user.username}

+ {#if profileData().data.profileDetail.createdAt} +

Member Since: {new Date(profileData().data.profileDetail.createdAt).toLocaleDateString()}

+ {/if} +
+
+
+
+ {:else} +
+

Profile not found

+
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/profiles/[id]/edit/+page.svelte b/frontend/src/routes/profiles/[id]/edit/+page.svelte new file mode 100644 index 0000000..14e069a --- /dev/null +++ b/frontend/src/routes/profiles/[id]/edit/+page.svelte @@ -0,0 +1,85 @@ + + +
+
+
+
+
+

+ Edit Profile +

+ + {#if !canEdit} +
+

You don't have permission to edit this profile

+
+ {:else if profileData().fetching} +
+
+
+ {:else if profileData().error} +
+

Error loading profile: {profileData().error?.message || 'Unknown error occurred'}

+
+ {:else if profileData().data?.profileDetail} +
+ +
+ {:else} +
+

Profile not found

+
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/projects/+page.svelte b/frontend/src/routes/projects/+page.svelte new file mode 100644 index 0000000..11d2ce1 --- /dev/null +++ b/frontend/src/routes/projects/+page.svelte @@ -0,0 +1,179 @@ + + +{#if isAuthenticated} +
+
+
+
+
+
+

+ Projects +

+ +
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ {#each filteredProjects as project (project.id)} +
+
+

+ {project.customer.name} {project.account ? `- ${project.account.name}` : ''} +

+
+ Status: {project.status} + • Team: {project.teamMemberNames?.join(', ')} +
+
+ Date: {project.date ? format(parseISO(project.date), 'MM/dd/yyyy') : 'N/A'} +
+ {#if project.notes} +
+ {project.notes} +
+ {/if} +
+ +
+ {/each} +
+
+
+
+
+
+{/if} diff --git a/frontend/src/routes/projects/[id]/+page.svelte b/frontend/src/routes/projects/[id]/+page.svelte new file mode 100644 index 0000000..e5bd85c --- /dev/null +++ b/frontend/src/routes/projects/[id]/+page.svelte @@ -0,0 +1,84 @@ + + +
+
+
+
+
+
+

+ {$project.data?.project.customer.name} +

+
+ + +
+
+ + {#if $project.data?.project} +
+ +
+

Project Information

+
+

Customer: {$project.data.project.customer.name}

+ {#if $project.data.project.account} +

Account: {$project.data.project.account.name}

+ {/if} +

Status: {$project.data.project.status}

+

+ Date: {format(parseISO($project.data.project.date), 'MM/dd/yyyy')} +

+
+
+ + +
+

Team Members

+
+

{$project.data.project.teamMemberNames?.join(', ')}

+
+
+ + + {#if $project.data.project.description} +
+

Description

+

{$project.data.project.description}

+
+ {/if} +
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/projects/[id]/edit/+page.svelte b/frontend/src/routes/projects/[id]/edit/+page.svelte new file mode 100644 index 0000000..2391c6d --- /dev/null +++ b/frontend/src/routes/projects/[id]/edit/+page.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {#if $project.data?.project} + + {/if} +
+
+
diff --git a/frontend/src/routes/projects/create/+page.svelte b/frontend/src/routes/projects/create/+page.svelte new file mode 100644 index 0000000..dbe6853 --- /dev/null +++ b/frontend/src/routes/projects/create/+page.svelte @@ -0,0 +1,42 @@ + + +
+
+
+ +
+
+
diff --git a/frontend/src/routes/reports/+page.svelte b/frontend/src/routes/reports/+page.svelte new file mode 100644 index 0000000..f7aebd0 --- /dev/null +++ b/frontend/src/routes/reports/+page.svelte @@ -0,0 +1,179 @@ + + +{#if isAuthenticated} +
+
+
+
+
+
+

+ Reports +

+ {#if canManage} + + {/if} +
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ {#each filteredReports as report (report.id)} +
+
+

+ {report.teamMember.firstName} {report.teamMember.lastName} - {report.date ? format(parseISO(report.date), 'MM/dd/yyyy') : 'N/A'} +

+
+ Services: {report.serviceCount} • Projects: {report.projectCount} +
+ {#if report.notes} +
+ Notes: {report.notes} +
+ {/if} +
+ +
+ {/each} +
+
+
+
+
+
+{/if} diff --git a/frontend/src/routes/reports/[id]/+page.svelte b/frontend/src/routes/reports/[id]/+page.svelte new file mode 100644 index 0000000..1816ba2 --- /dev/null +++ b/frontend/src/routes/reports/[id]/+page.svelte @@ -0,0 +1,105 @@ + + +
+
+
+
+
+
+

+ {$report.data?.reportDetail.teamMember.firstName} {$report.data?.reportDetail.teamMember.lastName}'s Report +

+
+ {#if canManage} + + {/if} + +
+
+ + {#if $report.data?.reportDetail} +
+ +
+

Report Information

+
+

Team Member: {$report.data.reportDetail.teamMember.firstName} {$report.data.reportDetail.teamMember.lastName}

+

Date: {format(parseISO($report.data.reportDetail.date), 'MM/dd/yyyy')}

+

Created: {new Date($report.data.reportDetail.createdAt).toLocaleString()}

+

Updated: {new Date($report.data.reportDetail.updatedAt).toLocaleString()}

+
+
+ + +
+

Services ({$report.data.reportDetail.serviceCount})

+
+ {#if $report.data.reportDetail.services && $report.data.reportDetail.services.length > 0} + {#each $report.data.reportDetail.services as service (service.id)} +

{service.account?.name}

+ {/each} + {:else} +

No services reported

+ {/if} +
+
+ + +
+

Projects ({$report.data.reportDetail.projectCount})

+
+ {#if $report.data.reportDetail.projects && $report.data.reportDetail.projects.length > 0} + {#each $report.data.reportDetail.projects as project (project.id)} +

{project.customer?.name}

+ {/each} + {:else} +

No projects reported

+ {/if} +
+
+ + + {#if $report.data.reportDetail.notes} +
+

Notes

+

{$report.data.reportDetail.notes}

+
+ {/if} +
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/reports/[id]/edit/+page.svelte b/frontend/src/routes/reports/[id]/edit/+page.svelte new file mode 100644 index 0000000..4201e98 --- /dev/null +++ b/frontend/src/routes/reports/[id]/edit/+page.svelte @@ -0,0 +1,32 @@ + + +
+
+
+ {#if $report.data?.reportDetail} + + {/if} +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/reports/create/+page.svelte b/frontend/src/routes/reports/create/+page.svelte new file mode 100644 index 0000000..0c28cfa --- /dev/null +++ b/frontend/src/routes/reports/create/+page.svelte @@ -0,0 +1,37 @@ + + +
+
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/revenues/[id]/+page.svelte b/frontend/src/routes/revenues/[id]/+page.svelte new file mode 100644 index 0000000..eac3a26 --- /dev/null +++ b/frontend/src/routes/revenues/[id]/+page.svelte @@ -0,0 +1,73 @@ + + +
+
+
+
+
+
+

+ Revenue Details +

+ +
+ + {#if $revenue.fetching} +
+
+

Loading revenue details...

+
+ {:else if $revenue.error} +
+

Error loading revenue: {$revenue.error.message}

+
+ {:else if $revenue.data?.revenue} + + {:else} +
+

Revenue not found.

+ +
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/revenues/[id]/edit/+page.svelte b/frontend/src/routes/revenues/[id]/edit/+page.svelte new file mode 100644 index 0000000..27231c1 --- /dev/null +++ b/frontend/src/routes/revenues/[id]/edit/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+
+ {#if $revenue.fetching} +
+
+

Loading revenue...

+
+ {:else if $revenue.error} +
+

Error loading revenue: {$revenue.error.message}

+
+ {:else if $revenue.data?.revenue} + + {:else} +
+

Revenue not found.

+ +
+ {/if} +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/revenues/create/+page.svelte b/frontend/src/routes/revenues/create/+page.svelte new file mode 100644 index 0000000..b0e21b8 --- /dev/null +++ b/frontend/src/routes/revenues/create/+page.svelte @@ -0,0 +1,25 @@ + + +
+
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/schedules/[id]/+page.svelte b/frontend/src/routes/schedules/[id]/+page.svelte new file mode 100644 index 0000000..d56ce63 --- /dev/null +++ b/frontend/src/routes/schedules/[id]/+page.svelte @@ -0,0 +1,96 @@ + + +
+
+
+
+
+
+

+ {$schedule.data?.schedule.account.name} Schedule +

+
+ + +
+
+ + {#if $schedule.data?.schedule} +
+ +
+

Schedule Information

+
+

Account: {$schedule.data.schedule.account.name}

+

Start Date: {formatDate($schedule.data.schedule.startDate)}

+ {#if $schedule.data.schedule.endDate} +

End Date: {formatDate($schedule.data.schedule.endDate)}

+ {/if} +

Status: {$schedule.data.schedule.isActive ? 'Active' : 'Inactive'}

+
+
+ + +
+

Service Days

+
+ {#if $schedule.data.schedule.serviceDays && $schedule.data.schedule.serviceDays.length > 0} +

{$schedule.data.schedule.serviceDays.join(', ')}

+ {:else} +

No service days specified

+ {/if} + + {#if $schedule.data.schedule.weekendService} +

Weekend service is enabled

+ {/if} +
+
+ + + {#if $schedule.data.schedule.scheduleException} +
+

Schedule Exception

+

{$schedule.data.schedule.scheduleException}

+
+ {/if} +
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/schedules/[id]/edit/+page.svelte b/frontend/src/routes/schedules/[id]/edit/+page.svelte new file mode 100644 index 0000000..0674cdc --- /dev/null +++ b/frontend/src/routes/schedules/[id]/edit/+page.svelte @@ -0,0 +1,30 @@ + + +
+
+
+ {#if $schedule.data?.schedule} + + {/if} +
+
+
diff --git a/frontend/src/routes/schedules/create/+page.svelte b/frontend/src/routes/schedules/create/+page.svelte new file mode 100644 index 0000000..800549d --- /dev/null +++ b/frontend/src/routes/schedules/create/+page.svelte @@ -0,0 +1,25 @@ + + +
+
+
+ +
+
+
\ No newline at end of file diff --git a/frontend/src/routes/services/+page.svelte b/frontend/src/routes/services/+page.svelte new file mode 100644 index 0000000..eb6fd9c --- /dev/null +++ b/frontend/src/routes/services/+page.svelte @@ -0,0 +1,182 @@ + + +{#if isAuthenticated} +
+
+
+
+
+
+

+ Services +

+
+ + +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+ {#each filteredServices as service (service.id)} +
+
+

+ {service.account.name} - {service.date ? format(parseISO(service.date), 'MM/dd/yyyy') : 'N/A'} +

+
+ Status: {service.status} • Team: {service.teamMemberNames?.join(', ')} +
+ {#if service.notes} +
+ Notes: {service.notes} +
+ {/if} +
+ +
+ {/each} +
+
+
+
+
+
+{/if} diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte new file mode 100644 index 0000000..3cafe1d --- /dev/null +++ b/frontend/src/routes/services/[id]/+page.svelte @@ -0,0 +1,95 @@ + + +
+
+
+
+
+
+

+ {$service.data?.service.account.name} +

+
+ {#if canManage} + + {/if} + +
+
+ + {#if $service.data?.service} +
+ +
+

Service Information

+
+

Account: {$service.data.service.account.name}

+

Date: {format(parseISO($service.data.service.date), 'MM/dd/yyyy')}

+

Status: {$service.data.service.status}

+
+
+ + +
+

Team Members

+
+ {#each $service.data.service.teamMembers || [] as member (member.id)} +

{member.firstName} {member.lastName}

+ {/each} +
+
+ + +
+

Service Window

+
+

Start: {new Date($service.data.service.deadlineStart).toLocaleString()}

+

End: {new Date($service.data.service.deadlineEnd).toLocaleString()}

+
+
+ + + {#if $service.data.service.notes} +
+

Notes

+

{$service.data.service.notes}

+
+ {/if} +
+ {/if} +
+
+
+
+
diff --git a/frontend/src/routes/services/[id]/edit/+page.svelte b/frontend/src/routes/services/[id]/edit/+page.svelte new file mode 100644 index 0000000..a961f61 --- /dev/null +++ b/frontend/src/routes/services/[id]/edit/+page.svelte @@ -0,0 +1,32 @@ + + +
+
+
+ {#if $service.data?.service} + + {/if} +
+
+
diff --git a/frontend/src/routes/services/bulk-schedule/+page.svelte b/frontend/src/routes/services/bulk-schedule/+page.svelte new file mode 100644 index 0000000..4d58e10 --- /dev/null +++ b/frontend/src/routes/services/bulk-schedule/+page.svelte @@ -0,0 +1,362 @@ + + +{#if isAuthenticated} +
+
+
+
+
+
+

+ Bulk Schedule Services +

+ +
+ + {#if currentStep === 1} + +
+ +
+

Select Month and Year

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

Select Accounts

+
+ + +
+
+ + {#if $accounts.fetching} +
+
+

Loading accounts...

+
+ {:else if $accounts.error} +
+

Error loading accounts: {$accounts.error.message}

+
+ {:else if accountsWithSchedules.length === 0} +
+

No accounts with active schedules found.

+
+ {:else} +
+
+ {#each accountsWithSchedules as account (account.id)} + + {/each} +
+
+ {/if} +
+ + {#if error} +
+ {error} +
+ {/if} + +
+ +
+
+ {:else if currentStep === 2} + +
+
+

Processing Bulk Schedule

+

+ Scheduling services for {selectedAccounts.length} accounts for {selectedMonth}/{selectedYear}... +

+
+ {:else if currentStep === 3} + +
+
+

Bulk Scheduling Complete

+

Services have been scheduled successfully. You will be redirected to the services page in a few seconds.

+
+ +
+

Results

+
+ {#if processingResults && processingResults.account_results} +
+ {#each Object.entries(processingResults.account_results) as [accountId, result] (accountId)} +
+
+

{result.account_name}

+ {#if result.success} + + Success + + {:else} + + Failed + + {/if} +
+

+ {result.message} +

+ {#if result.errors && result.errors.length > 0} +
+

Errors:

+
    + {#each result.errors as error, i (i)} +
  • {error}
  • + {/each} +
+
+ {/if} +
+ {/each} +
+ {:else} +

No detailed results available.

+ {/if} +
+
+ +
+ +
+
+ {/if} +
+
+
+
+
+{/if} diff --git a/frontend/src/routes/services/create/+page.svelte b/frontend/src/routes/services/create/+page.svelte new file mode 100644 index 0000000..1d29105 --- /dev/null +++ b/frontend/src/routes/services/create/+page.svelte @@ -0,0 +1,38 @@ + + +
+
+
+ +
+
+
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..415a6c3 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,9 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +const config = { + preprocess: vitePreprocess(), + kit: { adapter: adapter() } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..0b2d886 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..2d35c4f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});