diff --git a/.gitignore b/.gitignore index 5ef6a52..f390d12 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..59593bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,71 @@ - -# This is NOT the Next.js you know +# AGENTS.md — Cotizador E3 -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - +## Quick Reference + +| Command | Purpose | +|---------|---------| +| `npm run dev` | Start Next.js dev server on port 3000 | +| `npm run build` | Production build (runs TypeScript check) | +| `npm run lint` | ESLint (flat config, eslint-config-next) | +| `npx tsx prisma/seed.ts` | Run seed (upserts all data, idempotent) | +| `npx prisma migrate dev` | Create/apply migration | +| `npx prisma generate` | Regenerate Prisma client | +| `npx prisma studio` | Prisma Studio GUI | + +**Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`. + +**Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`. + +## Prisma 7 — Critical Gotchas + +- **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`. +- **Uses `PrismaPg` driver adapter** (from `@prisma/adapter-pg`) with individual connection params (host/port/user/password/database), NOT a connection string. The `connectionString` approach causes SCRAM auth errors with postgres:postgres. +- **`prisma.config.ts`** has no `seed` property (unsupported by PrismaConfig type). Run seed manually with `npx tsx prisma/seed.ts`. +- After changing `schema.prisma`: run `npx prisma migrate dev`, then `npx prisma generate`. +- The `globalForPrisma` singleton pattern in `src/lib/db.ts` prevents hot-reload connection leaks in dev. + +## PDFKit — Server-Side PDF + +- `pdfkit` is excluded from Next.js bundling via `serverExternalPackages` in `next.config.ts`. Without this, font metric files (`.afm`) can't be resolved at runtime. +- **Footer auto-page-break bug:** `doc.text()` at `y > page.height - margins.bottom` triggers pdfkit's automatic page addition. To write footers in the bottom margin, temporarily set `doc.page.margins.bottom = 0`, write, then restore. Use `bufferPages: true` + `switchToPage()` to add footers after all content. +- Returns `Promise` — convert to `Uint8Array` for `NextResponse` body. + +## Next.js 16 Conventions + +- **Async params:** Dynamic route params are `Promise<{ id: string }>` — must `await params` before use. +- **All pages are `force-dynamic`** — no static generation or ISR. +- **Tailwind CSS v4** — no `tailwind.config.*` file. Config is in `postcss.config.mjs` (using `@tailwindcss/postcss`) and CSS custom properties via `@theme inline` in `src/app/globals.css`. + +## Data Model Notes + +- **Configuracion** is a key-value store (`clave`/`valor`), not a traditional settings model. New config keys: `color_primario`, `color_secundario`, `logo_base64` (branding for PDF export). +- **Cascading deletes:** Cotizacion → ServicioCotizado, PlanBucefaloCotizacion use `onDelete: Cascade`. +- Cotización number format: `UJ{YY}{MM}{AsesorInitials}{seq}` (e.g. `UJ2605AG001`) — see `generarNumeroCotizacion` in `src/lib/calculators.ts`. Sequence is 3 digits, zero-padded. +- Vigencia = 15 business days from fecha (excludes Sat/Sun) — `calcularVigencia`. +- IVA_RATE = 0.16 (16%). +- **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly). +- **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical. + +## Architecture + +``` +src/ + app/ # Next.js App Router pages + API routes + api/ # Route handlers (REST endpoints) + cotizaciones/[id]/editar/ # Edit mode (reuses CotizacionEditor via EditorLoader) + components/ # Shared client components (ExportButtons, Sidebar) + lib/ # Utilities: db.ts, store.ts, calculators.ts, pdf-generator.ts + generated/prisma/ # Prisma client output (gitignored) +prisma/ + schema.prisma # 8 models + seed.ts # All catalog data (23 services, bonos, planes, config) + migrations/ # Prisma migration files +``` + +- **Zustand store** (`src/lib/store.ts`) manages cotización editor state (draft form). Used by both new and edit pages. +- **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body). +- The old standalone `ExportExcelButton.tsx` was deleted — all exports now go through `ExportButtons.tsx`. + +## Domain + +Quotation system for Consultoría E3 (digital marketing agency in Querétaro, MX). Services organized in 4 phases: Fase 0 (Auditorías), Fase 1 (Setup/Infra), Fase 2 (Publicidad/Manejo), Fase 3 (Contenido/SEO). Two payment types: `unico` (one-time) and `mensual` (recurring). Optional Bucéfalo CRM plans and Openpay financing. diff --git a/COTIZACIÓN E3 2025 (1).xlsx b/COTIZACIÓN E3 2025 (1).xlsx new file mode 100644 index 0000000..aa12806 Binary files /dev/null and b/COTIZACIÓN E3 2025 (1).xlsx differ diff --git a/COTIZACIÓN E3 2025 .xlsx b/COTIZACIÓN E3 2025 .xlsx new file mode 100644 index 0000000..424a0cf Binary files /dev/null and b/COTIZACIÓN E3 2025 .xlsx differ diff --git a/COTIZACIÓN E3 2026 - Plan de Acompañamiento.xlsx b/COTIZACIÓN E3 2026 - Plan de Acompañamiento.xlsx new file mode 100644 index 0000000..53456f2 Binary files /dev/null and b/COTIZACIÓN E3 2026 - Plan de Acompañamiento.xlsx differ diff --git a/Copia de COTIZACIÓN E3 2026 (la buena) (1).xlsx b/Copia de COTIZACIÓN E3 2026 (la buena) (1).xlsx new file mode 100644 index 0000000..f7adb62 Binary files /dev/null and b/Copia de COTIZACIÓN E3 2026 (la buena) (1).xlsx differ diff --git a/Copia de COTIZACIÓN E3 2026 (la buena) (2).xlsx b/Copia de COTIZACIÓN E3 2026 (la buena) (2).xlsx new file mode 100644 index 0000000..0697bde Binary files /dev/null and b/Copia de COTIZACIÓN E3 2026 (la buena) (2).xlsx differ diff --git a/Copia de COTIZACIÓN E3 2026 (la buena) .xlsx b/Copia de COTIZACIÓN E3 2026 (la buena) .xlsx new file mode 100644 index 0000000..2cf3df5 Binary files /dev/null and b/Copia de COTIZACIÓN E3 2026 (la buena) .xlsx differ diff --git a/api/COTIZADOR_API_SKILL.md b/api/COTIZADOR_API_SKILL.md new file mode 100644 index 0000000..9399420 --- /dev/null +++ b/api/COTIZADOR_API_SKILL.md @@ -0,0 +1,470 @@ +# Cotizador E3 — API Documentation for AI Skills + +## Overview + +REST API for Consultoría E3's digital marketing quotation system. Allows AI agents to manage clients, browse service catalogs, create quotations, calculate financing, generate PDF/Excel documents, and manage the full sales pipeline. + +**Base URL:** `http://localhost:8000` +**Auth:** API Key via `X-API-Key` header or `Authorization: Bearer ` +**Content-Type:** `application/json` +**OpenAPI Spec:** `GET /openapi.json` (no auth required) + +--- + +## Domain Context + +Consultoría E3 is a digital marketing agency in Querétaro, México. Services are organized in 4 project phases: + +- **Fase 0 — Auditoría / Acompañamiento:** Initial assessments and ongoing consulting +- **Fase 1 — Setup e Infraestructura:** One-time setup (websites, branding, CRM config) +- **Fase 2 — Publicidad y Manejo:** Ongoing advertising and social media management +- **Fase 3 — Contenido y SEO:** Content creation and search engine optimization + +Two payment types: +- **unico:** One-time payment +- **mensual:** Monthly recurring payment + +Currency: MXN (Mexican Pesos) or USD. IVA (tax) rate: 16%. + +--- + +## Authentication + +### API Key (for agents) +``` +X-API-Key: your-api-key-here +``` +or +``` +Authorization: Bearer your-api-key-here +``` + +### JWT (for human login) +``` +POST /auth/login +Body: { "email": "user@example.com", "password": "secret" } +Response: { "user": { "id", "name", "email", "role" } } +Sets cookie: cotizador-session (httpOnly, 7 days) +``` + +--- + +## Endpoints Reference + +### Health & Info + +#### GET /health +Health check endpoint. No auth required. +- **Response:** `{ "status": "ok", "timestamp": "2026-05-03T..." }` + +#### GET /api-info +API capabilities and version info. +- **Response:** `{ "name": "Cotizador E3 API", "version": "1.0.0", "capabilities": [...] }` + +--- + +### Clients (Clientes) + +#### GET /clientes +List clients with optional search and pagination. +- **Query params:** `?q=texto&page=1&limit=50` +- **Response:** `{ "data": [Cliente], "meta": { "total", "page", "limit", "pages" } }` + +#### POST /clientes +Create a new client. +- **Body:** +```json +{ + "nombre": "Juan Pérez", + "empresa": "ACME Corp", + "email": "juan@acme.com", + "telefono": "4421234567" +} +``` +- **Response (201):** Full Cliente object with `id` + +#### GET /clientes/{id} +Get client details including recent quotations. +- **Response:** Cliente object + `cotizaciones` array + +#### PUT /clientes/{id} +Update client information. +- **Body:** Same as POST (all fields optional for update) + +#### DELETE /clientes/{id} +Delete client. Returns 409 if client has quotations. +- **Response (200):** `{ "ok": true }` +- **Error (409):** `{ "error": "Cliente tiene N cotizaciones asociadas" }` + +--- + +### Service Catalog (Catálogo) + +#### GET /catalogo +List all active services with optional filters. +- **Query params:** `?fase=0&tipoPago=mensual&categoriaId=xxx&q=seo` +- **Response:** Array of ServicioCatalogo objects + +Each service: +```json +{ + "id": "cuid", + "nombre": "Posicionamiento SEO", + "descripcion": "...", + "fase": 3, + "tipoPago": "mensual", + "precioBase": 2900, + "tiempoEntrega": "7 - 14 dias", + "entregablesDefault": ["Keyword research", "On-page optimization"], + "categoriaId": "cuid", + "variante": null, + "activo": true, + "orden": 0 +} +``` + +#### POST /catalogo +Create a new service. +- **Body:** +```json +{ + "nombre": "Nuevo Servicio", + "descripcion": "Descripción del servicio", + "fase": 1, + "tipoPago": "unico", + "precioBase": 5000, + "tiempoEntrega": "7 - 14 dias", + "entregablesDefault": ["Entregable 1", "Entregable 2"], + "categoriaId": "cuid", + "variante": null, + "orden": 0 +} +``` + +#### GET /catalogo/{id} +Get service details with category info. + +#### PUT /catalogo/{id} +Update service. Same body as POST. + +#### DELETE /catalogo/{id} +Soft-delete if used in quotations, hard-delete otherwise. +- **Response:** `{ "ok": true, "archived": true|false }` + +--- + +### Categories (Categorías) + +Available: SEO (#10b981), Marketing (#6366f1), Paid Media (#f59e0b), Desarrollo Web (#3b82f6), Automatizaciones (#8b5cf6), CRM (#ec4899), Desarrollo Personalizado (#14b8a6) + +#### GET /categorias +List all categories ordered by `orden`. + +#### POST /categorias +- **Body:** `{ "nombre": "Nueva Cat", "descripcion": "...", "color": "#6b7280", "orden": 0 }` + +#### GET /categorias/{id} +Get category with its services. + +#### PUT /categorias/{id} +- **Body:** `{ "nombre", "descripcion", "color", "orden", "activo" }` + +#### DELETE /categorias/{id} +Returns 409 if category has associated services. + +--- + +### Quotations (Cotizaciones) + +#### GET /cotizaciones +List quotations with filters. +- **Query params:** `?estado=borrador&asesorId=xxx&clienteId=xxx&q=texto&desde=2026-01-01&hasta=2026-12-31&page=1&limit=50` +- **Response:** Array with full relations (cliente, asesor, servicios+catalogo, planBucefalo) + +#### POST /cotizaciones +Create a complete quotation. Auto-creates client if not found. +- **Body:** +```json +{ + "numero": "UJ2605AG001", + "fecha": "2026-05-03", + "vigencia": "2026-05-24", + "moneda": "MXN", + "tipoCambio": "NA", + "proyecto": "MKT Digital", + "esquemaPago": "Pago Unico/Mensual", + "incluirBonos": false, + "incluirFinanciamiento": false, + "observaciones": "", + "asesorId": "cuid", + "cliente": { + "nombre": "Juan Pérez", + "empresa": "ACME Corp", + "email": "juan@acme.com", + "telefono": "4421234567" + }, + "servicios": [ + { + "catalogoId": "cuid", + "nombre": "SEO On-Page", + "fase": 3, + "tipoPago": "mensual", + "precio": 2900, + "tiempoEntrega": "7 - 14 dias", + "entregables": ["Keyword research", "On-page SEO"] + } + ], + "planBucefalo": { + "nivel": "basico", + "precio": 1000 + } +} +``` + +#### GET /cotizaciones/{id} +Get quotation with all relations. + +#### PUT /cotizaciones/{id} +Update quotation. Replaces all services in a transaction. +- **Body:** Same as POST without `numero` and `asesorId`. Optional `estado`. + +#### DELETE /cotizaciones/{id} +Hard-delete with cascade. + +#### PATCH /cotizaciones/{id}/precio +Update price of a single service. +- **Body:** `{ "servicioId": "cuid", "precio": 7500 }` + +#### PATCH /cotizaciones/{id}/estado +Change quotation status. +- **Body:** `{ "estado": "enviada" }` — `borrador`, `enviada`, `aprobada`, `rechazada` + +#### POST /cotizaciones/{id}/duplicate +Duplicate quotation as new copy in `borrador` state. + +--- + +### Packages (Paquetes) + +#### GET /paquetes +List active packages with nested phases and services. + +#### POST /paquetes +- **Body:** +```json +{ + "nombre": "Paquete Básico", + "descripcion": "...", + "fases": [ + { "nombre": "Auditoría", "orden": 0 }, + { "nombre": "Setup", "orden": 1 } + ] +} +``` + +#### GET /paquetes/{id} +Get package with full nested structure. + +#### PUT /paquetes/{id} +- **Body:** `{ "nombre", "descripcion", "activo" }` + +#### DELETE /paquetes/{id} +Hard-delete with cascade. + +#### POST /paquetes/{id}/manage +Multi-action endpoint: +- `{ "action": "addFase", "nombre": "Fase 3", "orden": 2 }` +- `{ "action": "updateFase", "faseId": "cuid", "nombre": "New Name", "orden": 1 }` +- `{ "action": "deleteFase", "faseId": "cuid" }` +- `{ "action": "addServicio", "servicioCatalogoId": "cuid", "fasePaqueteId": "cuid" }` +- `{ "action": "removeServicio", "servicioCatalogoId": "cuid", "fasePaqueteId": "cuid" }` + +--- + +### Configuration (Configuración) + +Allowed keys: `color_primario`, `color_secundario`, `logo_base64`, `razon_social`, `rfc`, `domicilio_fiscal`, `cuenta_nacional`, `clabe_interbancaria`, `cuenta_internacional`, `cuenta_internacional_swift`, `hora_centinela`, `anualidad_hosting`, `iva`, `terminos_condiciones`, `no_incluye`, `notas_adicionales` + +#### GET /configuracion +- **Response:** `{ "color_primario": "#2563eb", "razon_social": "...", ... }` + +#### PUT /configuracion +Bulk upsert. Only whitelisted keys processed. +- **Body:** `{ "color_primario": "#ff0000", "razon_social": "Nuevo Nombre" }` + +--- + +### Bonuses (Bonos) + +Available: Centinela Web, Workshop Buyer Persona, Workshop Propuesta de Valor, Membresía Premium, Mes Gratis CRM, Script de Ventas + +#### GET /bonos + +--- + +### Financing (Financiamiento) + +| Months | Rate | Commission | Min Amount | +|--------|------|------------|------------| +| 3 | 7.7% | 2.5% | $300 | +| 6 | 10.7% | 2.5% | $600 | +| 9 | 13.7% | 2.5% | $900 | +| 12 | 16.7% | 2.5% | $1,200 | + +#### GET /financiamiento/planes + +#### POST /financiamiento/calcular +- **Body:** `{ "monto": 50000, "meses": 6 }` +- **Response:** `{ "pagoMensual", "ivaMensual", "totalMensual", "comisionTotal", "granTotal" }` + +#### GET /financiamiento/simulacion/{cotizacion_id} +Auto-calculate financing for existing quotation. + +--- + +### Export + +#### POST /export/pdf — PDF from draft data +#### GET /export/pdf/{id} — PDF from saved quotation +#### POST /export/excel — Excel from draft data +#### GET /export/excel/{id} — Excel from saved quotation +#### GET /export/catalogo — CSV of active services +#### GET /export/catalogo/plantilla — CSV import template + +--- + +### Import + +#### POST /import/catalogo +Bulk import services from CSV. `multipart/form-data` with `file` field. +- **Response:** `{ "creados": 5, "omitidos": 2, "errores": [...] }` + +--- + +## Business Rules + +### Quotation Number +Format: `UJ{YY}{MM}{AsesorInitials}{sequence}` — e.g., `UJ2605AG001` + +### Quotation Validity +15 business days from issue (excludes Sat/Sun). + +### CRM Bucefalo Plans +basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes + +### Client Deduplication +Matched by `(nombre, empresa)` pair. + +### Service Soft-Delete +If referenced by quotations → `activo=false`. Otherwise hard-delete. + +### Financing Formula +``` +comisionTotal = monto × comision% +montoConComision = monto + comisionTotal +pagoMensual = montoConComision × (1 + tasa) / meses +ivaMensual = pagoMensual × 0.16 +totalMensual = pagoMensual + ivaMensual +granTotal = totalMensual × meses +``` + +--- + +## Common Responses + +### Success (paginated) +```json +{ + "data": [...], + "meta": { "total": 45, "page": 1, "limit": 50, "pages": 1 } +} +``` + +### Error +```json +{ "error": "Message", "detail": "Details", "code": "ERROR_CODE" } +``` + +### HTTP Codes +200=OK, 201=Created, 204=No Content, 400=Bad Request, 401=Unauthorized, 404=Not Found, 409=Conflict, 500=Server Error + +--- + +## Agent Workflows + +### Create quotation +``` +1. GET /catalogo?fase=0 → audit services +2. GET /catalogo?fase=1 → setup services +3. GET /catalogo?fase=2&tipoPago=mensual → ongoing services +4. POST /financiamiento/calcular → simulate payments +5. POST /cotizaciones → create quotation +6. GET /export/pdf/{id} → generate PDF +``` + +### Review pipeline +``` +1. GET /cotizaciones?estado=borrador → pending +2. GET /cotizaciones?estado=enviada → awaiting +3. GET /cotizaciones?estado=aprobada → won +``` + +### Negotiate price +``` +1. GET /cotizaciones/{id} → current state +2. PATCH /cotizaciones/{id}/precio → adjust price +3. GET /export/pdf/{id} → regenerate PDF +``` + +--- + +## MCP Integration + +MCP server at `/mcp` for OpenClaw, Claude, ChatGPT. + +### Tools +| Tool | Description | +|------|-------------| +| `buscar_servicios` | Search catalog by phase, payment type, category, text | +| `crear_cotizacion` | Create complete quotation in one call | +| `obtener_cotizacion` | Get quotation details | +| `listar_cotizaciones` | List with filters | +| `cambiar_estado_cotizacion` | Change status | +| `actualizar_precio_servicio` | Adjust service price | +| `duplicar_cotizacion` | Clone as draft | +| `calcular_financiamiento` | Calculate payments | +| `generar_pdf_cotizacion` | Generate PDF | +| `obtener_configuracion` | Company config | +| `listar_bonos` | Available bonuses | +| `listar_planes_bucefalo` | CRM plans | + +### Resources +| Resource | Description | +|----------|-------------| +| `cotizador://servicios` | Full catalog | +| `cotizador://categorias` | Categories | +| `cotizador://configuracion` | Company config | + +--- + +## Environment + +``` +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=cotizador_e3 +API_KEY=your-secret-api-key +JWT_SECRET=your-jwt-secret +``` + +## Run + +```bash +pip install -r requirements.txt +uvicorn main:app --reload --host 0.0.0.0 --port 8000 +# Swagger: http://localhost:8000/docs +# ReDoc: http://localhost:8000/redoc +# OpenAPI: http://localhost:8000/openapi.json +``` diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..b3a66cf --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/__pycache__/__init__.cpython-312.pyc b/api/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..11c9248 Binary files /dev/null and b/api/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/app/__pycache__/config.cpython-312.pyc b/api/app/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..d4e163f Binary files /dev/null and b/api/app/__pycache__/config.cpython-312.pyc differ diff --git a/api/app/auth.py b/api/app/auth.py new file mode 100644 index 0000000..48b107f --- /dev/null +++ b/api/app/auth.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi import Header, HTTPException, status +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_DAYS = 7 + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)}) + return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict | None: + try: + return jwt.decode(token, settings.JWT_SECRET, algorithms=[ALGORITHM]) + except JWTError: + return None + + +async def get_api_key(x_api_key: str | None = Header(None)) -> str | None: + if x_api_key and x_api_key == settings.API_KEY: + return x_api_key + return None + + +async def require_auth( + authorization: str | None = Header(None), + x_api_key: str | None = Header(None), +) -> dict: + if x_api_key and x_api_key == settings.API_KEY: + return {"auth_type": "api_key"} + + if authorization: + scheme, _, token = authorization.partition(" ") + if scheme.lower() == "bearer" and token: + payload = decode_token(token) + if payload: + return {"auth_type": "jwt", **payload} + if x_api_key == settings.API_KEY: + return {"auth_type": "api_key"} + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..eb08b1a --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,18 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DB_HOST: str = "localhost" + DB_PORT: int = 5432 + DB_USER: str = "postgres" + DB_PASSWORD: str = "postgres" + DB_NAME: str = "cotizador_e3" + API_KEY: str = "change-me-to-a-secure-random-key" + JWT_SECRET: str = "change-me-to-a-secure-jwt-secret" + API_HOST: str = "0.0.0.0" + API_PORT: int = 8000 + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} + + +settings = Settings() diff --git a/api/app/database.py b/api/app/database.py new file mode 100644 index 0000000..80a2740 --- /dev/null +++ b/api/app/database.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncpg + +from app.config import settings + +pool: asyncpg.Pool | None = None + + +async def init_db() -> None: + global pool + pool = await asyncpg.create_pool( + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASSWORD, + database=settings.DB_NAME, + min_size=2, + max_size=10, + ) + + +async def close_db() -> None: + global pool + if pool: + await pool.close() + pool = None + + +async def get_pool() -> asyncpg.Pool: + if pool is None: + raise RuntimeError("Database pool not initialized. Call init_db() first.") + return pool diff --git a/api/app/dependencies.py b/api/app/dependencies.py new file mode 100644 index 0000000..e0a2e43 --- /dev/null +++ b/api/app/dependencies.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Query + +from app.database import get_pool + + +async def get_db(): + pool = await get_pool() + async with pool.acquire() as conn: + yield conn + + +class PaginationParams: + def __init__( + self, + page: Annotated[int, Query(ge=1)] = 1, + limit: Annotated[int, Query(ge=1, le=200)] = 50, + ): + self.page = page + self.limit = limit + self.offset = (page - 1) * limit + + +def parse_date(date_str: str | None): + if not date_str: + return None + from datetime import datetime + try: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except ValueError: + try: + return datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + return None diff --git a/api/app/mcp/__init__.py b/api/app/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/mcp/__pycache__/__init__.cpython-312.pyc b/api/app/mcp/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..c81a20e Binary files /dev/null and b/api/app/mcp/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/app/mcp/__pycache__/tools.cpython-312.pyc b/api/app/mcp/__pycache__/tools.cpython-312.pyc new file mode 100644 index 0000000..a12f5d5 Binary files /dev/null and b/api/app/mcp/__pycache__/tools.cpython-312.pyc differ diff --git a/api/app/mcp/server.py b/api/app/mcp/server.py new file mode 100644 index 0000000..d393f1a --- /dev/null +++ b/api/app/mcp/server.py @@ -0,0 +1,446 @@ +"""MCP Server for the Cotizador E3 API. + +Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.) +to interact with the quotation system. +""" + +from __future__ import annotations + +import json +from datetime import datetime + +from mcp.server import Server +from mcp.types import Resource, TextContent, Tool + +from app.database import get_pool +from app.mcp.tools import RESOURCES, TOOLS +from app.services.calculators import ( + BONOS, + PLANES_BUCEFALO, + calcular_financiamiento, + bucefalo_precio, +) + +server = Server("cotizador-e3") + + +@server.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name=t["name"], + description=t["description"], + inputSchema=t["inputSchema"], + ) + for t in TOOLS + ] + + +@server.list_resources() +async def list_resources() -> list[Resource]: + return [ + Resource( + uri=r["uri"], + name=r["name"], + description=r["description"], + mimeType=r["mimeType"], + ) + for r in RESOURCES + ] + + +@server.read_resource() +async def read_resource(uri: str) -> str: + pool = await get_pool() + async with pool.acquire() as conn: + if uri == "cotizador://servicios": + rows = await conn.fetch( + """SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago", + s."precioBase", s."tiempoEntrega", s."entregablesDefault", + s.variante, s.activo, s.orden, + c.nombre as categoria_nombre + FROM "ServicioCatalogo" s + LEFT JOIN "Categoria" c ON s."categoriaId" = c.id + WHERE s.activo = true + ORDER BY s.fase ASC, s.orden ASC""" + ) + return json.dumps([dict(r) for r in rows], default=str) + + elif uri == "cotizador://categorias": + rows = await conn.fetch( + 'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC' + ) + return json.dumps([dict(r) for r in rows], default=str) + + elif uri == "cotizador://configuracion": + rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"') + return json.dumps({r["clave"]: r["valor"] for r in rows}) + + return json.dumps({"error": f"Unknown resource: {uri}"}) + + +@server.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + pool = await get_pool() + async with pool.acquire() as conn: + result = await _handle_tool(conn, name, arguments) + return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))] + + +async def _handle_tool(conn, name: str, arguments: dict) -> dict: + if name == "buscar_servicios": + return await _buscar_servicios(conn, arguments) + elif name == "crear_cotizacion": + return await _crear_cotizacion(conn, arguments) + elif name == "obtener_cotizacion": + return await _obtener_cotizacion(conn, arguments) + elif name == "listar_cotizaciones": + return await _listar_cotizaciones(conn, arguments) + elif name == "cambiar_estado_cotizacion": + return await _cambiar_estado(conn, arguments) + elif name == "actualizar_precio_servicio": + return await _actualizar_precio(conn, arguments) + elif name == "duplicar_cotizacion": + return await _duplicar_cotizacion(conn, arguments) + elif name == "calcular_financiamiento": + return await _calcular_financiamiento(arguments) + elif name == "generar_pdf_cotizacion": + return await _generar_pdf(conn, arguments) + elif name == "obtener_configuracion": + return await _obtener_configuracion(conn) + elif name == "listar_bonos": + return {"bonos": BONOS} + elif name == "listar_planes_bucefalo": + return {"planes": PLANES_BUCEFALO} + else: + return {"error": f"Unknown tool: {name}"} + + +async def _buscar_servicios(conn, args: dict) -> dict: + conditions = ['s.activo = true'] + params = [] + idx = 1 + + if args.get("fase") is not None: + conditions.append(f's.fase = ${idx}') + params.append(args["fase"]) + idx += 1 + if args.get("tipo_pago"): + conditions.append(f's."tipoPago" = ${idx}') + params.append(args["tipo_pago"]) + idx += 1 + if args.get("categoria"): + conditions.append(f'LOWER(c.nombre) = LOWER(${idx})') + params.append(args["categoria"]) + idx += 1 + if args.get("busqueda"): + conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))') + params.append(f"%{args['busqueda']}%") + idx += 1 + + where = " AND ".join(conditions) + query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago", + s."precioBase", s."tiempoEntrega", s."entregablesDefault", + s.variante, c.nombre as categoria + FROM "ServicioCatalogo" s + LEFT JOIN "Categoria" c ON s."categoriaId" = c.id + WHERE {where} + ORDER BY s.fase ASC, s.orden ASC""" + + rows = await conn.fetch(query, *params) + servicios = [] + for r in rows: + d = dict(r) + if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str): + try: + d["entregablesDefault"] = json.loads(d["entregablesDefault"]) + except (json.JSONDecodeError, TypeError): + pass + servicios.append(d) + + return {"servicios": servicios, "total": len(servicios)} + + +async def _crear_cotizacion(conn, args: dict) -> dict: + cliente_data = args["cliente"] + servicios_data = args["servicios"] + plan_bucefalo = args.get("plan_bucefalo") + moneda = args.get("moneda", "MXN") + proyecto = args.get("proyecto", "MKT Digital") + esquema = args.get("esquema_pago", "Pago Unico/Mensual") + + cliente = await conn.fetchrow( + 'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2', + cliente_data["nombre"], + cliente_data.get("empresa", ""), + ) + if not cliente: + cliente = await conn.fetchrow( + 'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id', + cliente_data["nombre"], + cliente_data.get("empresa", ""), + cliente_data.get("email", ""), + cliente_data.get("telefono", ""), + ) + + cliente_id = cliente["id"] + + now = datetime.now() + numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001" + + from app.services.calculators import calcular_vigencia + vigencia = calcular_vigencia(now) + + async with conn.transaction(): + cot = await conn.fetchrow( + """INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", + estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, '', $7, $8, NOW(), NOW()) + RETURNING id, numero""", + numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent", + ) + cot_id = cot["id"] + + for srv in servicios_data: + catalogo_id = srv["servicio_id"] + cat_row = await conn.fetchrow( + 'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1', + catalogo_id, + ) + if not cat_row: + continue + + precio = srv.get("precio_personalizado") or cat_row["precioBase"] + entregables = cat_row["entregablesDefault"] + if isinstance(entregables, str): + try: + entregables = json.loads(entregables) + except (json.JSONDecodeError, TypeError): + entregables = [] + + await conn.fetchrow( + """INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", + precio, "tiempoEntrega", entregables, seleccionado, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())""", + cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"], + precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), + ) + + if plan_bucefalo: + nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico") + precio_bp = bucefalo_precio(nivel) + await conn.fetchrow( + """INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""", + cot_id, nivel, precio_bp, + ) + + return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)} + + +async def _obtener_cotizacion(conn, args: dict) -> dict: + cot = await conn.fetchrow( + """SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa, + cl.email as cliente_email, cl.telefono as cliente_telefono + FROM "Cotizacion" c + LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id + WHERE c.id = $1""", + args["cotizacion_id"], + ) + if not cot: + return {"error": "Cotización no encontrada"} + + servicios = await conn.fetch( + """SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago" + FROM "ServicioCotizado" sc + LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id + WHERE sc."cotizacionId" = $1""", + args["cotizacion_id"], + ) + + plan = await conn.fetchrow( + 'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', + args["cotizacion_id"], + ) + + result = dict(cot) + result["cliente"] = { + "nombre": cot["cliente_nombre"], + "empresa": cot["cliente_empresa"], + "email": cot["cliente_email"], + "telefono": cot["cliente_telefono"], + } + result["servicios"] = [dict(s) for s in servicios] + result["planBucefalo"] = dict(plan) if plan else None + + for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]: + result.pop(k, None) + + return result + + +async def _listar_cotizaciones(conn, args: dict) -> dict: + conditions = [] + params = [] + idx = 1 + + if args.get("estado"): + conditions.append(f'c.estado = ${idx}') + params.append(args["estado"]) + idx += 1 + if args.get("cliente_nombre"): + conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})') + params.append(f"%{args['cliente_nombre']}%") + idx += 1 + if args.get("busqueda"): + conditions.append( + f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))' + ) + params.append(f"%{args['busqueda']}%") + idx += 1 + + where = "WHERE " + " AND ".join(conditions) if conditions else "" + query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto, + c.moneda, c."esquemaPago", + cl.nombre as cliente_nombre, cl.empresa as cliente_empresa + FROM "Cotizacion" c + LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id + {where} + ORDER BY c."createdAt" DESC + LIMIT 50""" + + rows = await conn.fetch(query, *params) + cotizaciones = [] + for r in rows: + d = dict(r) + d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")} + cotizaciones.append(d) + + return {"cotizaciones": cotizaciones, "total": len(cotizaciones)} + + +async def _cambiar_estado(conn, args: dict) -> dict: + cot_id = args["cotizacion_id"] + estado = args["estado"] + + cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id) + if not cot: + return {"error": "Cotización no encontrada"} + + await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id) + return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado} + + +async def _actualizar_precio(conn, args: dict) -> dict: + cot_id = args["cotizacion_id"] + servicio_id = args["servicio_id"] + nuevo_precio = args["nuevo_precio"] + + srv = await conn.fetchrow( + 'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2', + servicio_id, cot_id, + ) + if not srv: + return {"error": "Servicio no encontrado en esta cotización"} + + await conn.execute( + 'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2', + nuevo_precio, servicio_id, + ) + return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio} + + +async def _duplicar_cotizacion(conn, args: dict) -> dict: + cot_id = args["cotizacion_id"] + + original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) + if not original: + return {"error": "Cotización no encontrada"} + + now = datetime.now() + new_numero = f"{original['numero']}-COPY" + + async with conn.transaction(): + new_cot = await conn.fetchrow( + """INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", + estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, NOW(), NOW()) + RETURNING id, numero""", + new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"], + original["proyecto"], original["esquemaPago"], original["incluirBonos"], + original["incluirFinanciamiento"], original["observaciones"], + original["clienteId"], original["asesorId"], + ) + new_id = new_cot["id"] + + servicios = await conn.fetch( + 'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id + ) + for s in servicios: + await conn.fetchrow( + """INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", + precio, "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())""", + new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"], + s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"], + ) + + plan = await conn.fetchrow( + 'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id + ) + if plan: + await conn.fetchrow( + """INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""", + new_id, plan["nivel"], plan["precio"], plan["seleccionado"], + ) + + return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"} + + +async def _calcular_financiamiento(args: dict) -> dict: + monto = args["monto"] + meses = args["meses"] + + plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None) + if not plan: + from app.services.calculators import FINANCIAMIENTO_PLANES + plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None) + + if not plan: + return {"error": f"Plan de {meses} meses no disponible"} + + result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"]) + result["meses"] = meses + result["tasa"] = plan["tasa"] + result["comision"] = plan["comision"] + return result + + +async def _generar_pdf(conn, args: dict) -> dict: + cot_id = args["cotizacion_id"] + cot = await conn.fetchrow( + """SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa + FROM "Cotizacion" c + LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id + WHERE c.id = $1""", + cot_id, + ) + if not cot: + return {"error": "Cotización no encontrada"} + + return { + "status": "pdf_generated", + "cotizacion_id": cot_id, + "numero": cot["numero"], + "filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf", + "message": "PDF generation will be implemented with reportlab", + } + + +async def _obtener_configuracion(conn) -> dict: + rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"') + return {"config": {r["clave"]: r["valor"] for r in rows}} + + +from app.services.calculators import FINANCIAMIENTO_PLANES diff --git a/api/app/mcp/tools.py b/api/app/mcp/tools.py new file mode 100644 index 0000000..00d38a0 --- /dev/null +++ b/api/app/mcp/tools.py @@ -0,0 +1,289 @@ +"""MCP (Model Context Protocol) tool definitions for the Cotizador API. + +Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT. +""" + +TOOLS = [ + { + "name": "buscar_servicios", + "description": ( + "Busca servicios del catálogo de marketing digital de Consultoría E3. " + "Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. " + "Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), " + "2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). " + "Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)." + ), + "inputSchema": { + "type": "object", + "properties": { + "fase": { + "type": "integer", + "enum": [0, 1, 2, 3], + "description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO", + }, + "tipo_pago": { + "type": "string", + "enum": ["unico", "mensual"], + "description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente", + }, + "categoria": { + "type": "string", + "description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado", + }, + "busqueda": { + "type": "string", + "description": "Texto libre para buscar en nombre y descripción del servicio", + }, + }, + }, + }, + { + "name": "crear_cotizacion", + "description": ( + "Crea una cotización completa de servicios de marketing digital para un cliente. " + "El cliente se crea automáticamente si no existe (busca por nombre+empresa). " + "Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, " + "y configuración de moneda y esquema de pago. " + "La cotización se crea en estado 'borrador'. " + "Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes." + ), + "inputSchema": { + "type": "object", + "properties": { + "cliente": { + "type": "object", + "properties": { + "nombre": {"type": "string", "description": "Nombre completo del contacto"}, + "empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"}, + "email": {"type": "string", "description": "Email de contacto"}, + "telefono": {"type": "string", "description": "Teléfono de contacto"}, + }, + "required": ["nombre"], + }, + "servicios": { + "type": "array", + "items": { + "type": "object", + "properties": { + "servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"}, + "precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"}, + }, + "required": ["servicio_id"], + }, + "description": "Lista de servicios a incluir en la cotización", + }, + "plan_bucefalo": { + "type": "string", + "enum": ["basico", "estandar", "premium", "empresarial"], + "description": "Nivel del plan CRM Bucefalo (opcional)", + }, + "proyecto": { + "type": "string", + "description": "Nombre o descripción del proyecto (default: 'MKT Digital')", + }, + "moneda": { + "type": "string", + "enum": ["MXN", "USD"], + "description": "Moneda de la cotización (default: MXN)", + }, + "esquema_pago": { + "type": "string", + "enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"], + "description": "Esquema de pago (default: Pago Unico/Mensual)", + }, + }, + "required": ["cliente", "servicios"], + }, + }, + { + "name": "obtener_cotizacion", + "description": ( + "Obtiene los detalles completos de una cotización existente incluyendo: " + "datos del cliente, servicios seleccionados con precios, estado actual, " + "plan CRM Bucefalo si aplica, observaciones, fechas y vigencia." + ), + "inputSchema": { + "type": "object", + "properties": { + "cotizacion_id": {"type": "string", "description": "ID de la cotización"}, + }, + "required": ["cotizacion_id"], + }, + }, + { + "name": "listar_cotizaciones", + "description": ( + "Lista cotizaciones con filtros opcionales. " + "Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. " + "Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)." + ), + "inputSchema": { + "type": "object", + "properties": { + "estado": { + "type": "string", + "enum": ["borrador", "enviada", "aprobada", "rechazada"], + "description": "Filtrar por estado", + }, + "cliente_nombre": { + "type": "string", + "description": "Buscar por nombre de cliente", + }, + "busqueda": { + "type": "string", + "description": "Texto libre para buscar en número, proyecto o cliente", + }, + }, + }, + }, + { + "name": "cambiar_estado_cotizacion", + "description": ( + "Cambia el estado de una cotización. " + "Flujo normal: borrador → enviada → aprobada o rechazada. " + "Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. " + "Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline." + ), + "inputSchema": { + "type": "object", + "properties": { + "cotizacion_id": {"type": "string", "description": "ID de la cotización"}, + "estado": { + "type": "string", + "enum": ["borrador", "enviada", "aprobada", "rechazada"], + "description": "Nuevo estado de la cotización", + }, + }, + "required": ["cotizacion_id", "estado"], + }, + }, + { + "name": "actualizar_precio_servicio", + "description": ( + "Actualiza el precio de un servicio específico dentro de una cotización. " + "No modifica el precio base del catálogo, solo el precio en esta cotización. " + "Útil para negociar precios individuales sin recrear toda la cotización." + ), + "inputSchema": { + "type": "object", + "properties": { + "cotizacion_id": {"type": "string", "description": "ID de la cotización"}, + "servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"}, + "nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"}, + }, + "required": ["cotizacion_id", "servicio_id", "nuevo_precio"], + }, + }, + { + "name": "duplicar_cotizacion", + "description": ( + "Duplica una cotización existente como nueva copia en estado 'borrador'. " + "Crea una copia exacta con nuevo ID y número. " + "Útil para crear variaciones de una propuesta o reenviar una cotización actualizada." + ), + "inputSchema": { + "type": "object", + "properties": { + "cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"}, + }, + "required": ["cotizacion_id"], + }, + }, + { + "name": "calcular_financiamiento", + "description": ( + "Calcula las mensualidades para financiar una cotización o monto específico. " + "Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). " + "Todos incluyen 2.5% de comisión + 16% IVA. " + "Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total." + ), + "inputSchema": { + "type": "object", + "properties": { + "monto": {"type": "number", "description": "Monto total a financiar en MXN"}, + "meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"}, + }, + "required": ["monto", "meses"], + }, + }, + { + "name": "generar_pdf_cotizacion", + "description": ( + "Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, " + "tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, " + "y datos bancarios para transferencia. Devuelve el archivo PDF." + ), + "inputSchema": { + "type": "object", + "properties": { + "cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"}, + }, + "required": ["cotizacion_id"], + }, + }, + { + "name": "obtener_configuracion", + "description": ( + "Obtiene la configuración de la empresa Consultoría E3: " + "razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), " + "colores de marca, logo, y términos y condiciones. " + "Útil para generar documentos o verificar información fiscal." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "listar_bonos", + "description": ( + "Lista los bonos/disponibles que se pueden incluir en una cotización: " + "1) Servicio Centinela Web (monitoreo 30 min/mes), " + "2) Workshop Buyer Persona, " + "3) Workshop Propuesta de Valor, " + "4) Membresía Premium (1 año), " + "5) Mes Gratis CRM Bucefalo, " + "6) Script de Ventas (100+ complementos)." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "listar_planes_bucefalo", + "description": ( + "Lista los niveles del CRM Bucefalo con precios mensuales: " + "Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). " + "Bucefalo es un CRM para gestión de ventas y clientes." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, +] + +RESOURCES = [ + { + "uri": "cotizador://servicios", + "name": "Catálogo de Servicios", + "description": ( + "Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: " + "Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), " + "Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, " + "tiempo de entrega, tipo de pago (único/mensual), y entregables." + ), + "mimeType": "application/json", + }, + { + "uri": "cotizador://categorias", + "name": "Categorías de Servicios", + "description": ( + "Categorías disponibles para clasificar servicios: " + "SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado." + ), + "mimeType": "application/json", + }, + { + "uri": "cotizador://configuracion", + "name": "Configuración de la Empresa", + "description": ( + "Datos fiscales, bancarios y de marca de Consultoría E3. " + "Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, " + "colores de marca, logo, y términos y condiciones." + ), + "mimeType": "application/json", + }, +] diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/models/__pycache__/__init__.cpython-312.pyc b/api/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a534cd6 Binary files /dev/null and b/api/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/app/models/__pycache__/common.cpython-312.pyc b/api/app/models/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000..f8ff4ae Binary files /dev/null and b/api/app/models/__pycache__/common.cpython-312.pyc differ diff --git a/api/app/models/__pycache__/cotizacion.cpython-312.pyc b/api/app/models/__pycache__/cotizacion.cpython-312.pyc new file mode 100644 index 0000000..508075e Binary files /dev/null and b/api/app/models/__pycache__/cotizacion.cpython-312.pyc differ diff --git a/api/app/models/__pycache__/export_.cpython-312.pyc b/api/app/models/__pycache__/export_.cpython-312.pyc new file mode 100644 index 0000000..eb871e8 Binary files /dev/null and b/api/app/models/__pycache__/export_.cpython-312.pyc differ diff --git a/api/app/models/catalogo.py b/api/app/models/catalogo.py new file mode 100644 index 0000000..7f5a6bb --- /dev/null +++ b/api/app/models/catalogo.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CategoriaBase(BaseModel): + nombre: str = Field(..., min_length=1, description="Nombre de la categoría") + descripcion: str | None = None + color: str = Field("#6b7280", description="Color hex para UI") + orden: int = Field(0, ge=0) + + +class CategoriaCreate(CategoriaBase): + pass + + +class CategoriaUpdate(BaseModel): + nombre: str | None = Field(None, min_length=1) + descripcion: str | None = None + color: str | None = None + orden: int | None = Field(None, ge=0) + activo: bool | None = None + + +class CategoriaResponse(CategoriaBase): + id: str + activo: bool + createdAt: datetime + updatedAt: datetime + + model_config = {"from_attributes": True} + + +class ServicioCatalogoBase(BaseModel): + nombre: str = Field(..., min_length=1, description="Nombre del servicio") + descripcion: str | None = None + fase: int = Field(..., ge=0, le=3, description="Fase: 0=Auditoría, 1=Setup, 2=Publicidad, 3=Contenido/SEO") + tipoPago: str = Field(..., pattern="^(unico|mensual)$", description="Tipo de pago") + precioBase: float = Field(..., ge=0, description="Precio base en la moneda local") + tiempoEntrega: str = Field("7 - 14 dias", description="Tiempo de entrega estimado") + entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables") + categoriaId: str = Field(..., min_length=1, description="ID de la categoría") + variante: str | None = None + orden: int = Field(0, ge=0) + + +class ServicioCatalogoCreate(ServicioCatalogoBase): + pass + + +class ServicioCatalogoUpdate(BaseModel): + nombre: str | None = Field(None, min_length=1) + descripcion: str | None = None + fase: int | None = Field(None, ge=0, le=3) + tipoPago: str | None = Field(None, pattern="^(unico|mensual)$") + precioBase: float | None = Field(None, ge=0) + tiempoEntrega: str | None = None + entregablesDefault: list[str] | None = None + categoriaId: str | None = None + variante: str | None = None + activo: bool | None = None + orden: int | None = Field(None, ge=0) + + +class ServicioCatalogoResponse(ServicioCatalogoBase): + id: str + activo: bool + createdAt: datetime + updatedAt: datetime + categoriaRel: CategoriaResponse | None = None + + model_config = {"from_attributes": True} + + +from app.models.cliente import ClienteResponse diff --git a/api/app/models/cliente.py b/api/app/models/cliente.py new file mode 100644 index 0000000..bff5d85 --- /dev/null +++ b/api/app/models/cliente.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ClienteBase(BaseModel): + nombre: str = Field(..., min_length=1, description="Nombre completo del contacto") + empresa: str | None = Field(None, description="Nombre de la empresa") + email: str | None = Field(None, description="Email de contacto") + telefono: str | None = Field(None, description="Teléfono de contacto") + + +class ClienteCreate(ClienteBase): + pass + + +class ClienteUpdate(BaseModel): + nombre: str | None = Field(None, min_length=1) + empresa: str | None = None + email: str | None = None + telefono: str | None = None + + +class ClienteResponse(ClienteBase): + id: str + createdAt: datetime + updatedAt: datetime + + model_config = {"from_attributes": True} + + +class ClienteWithCotizaciones(ClienteResponse): + cotizaciones: list[dict] | None = None diff --git a/api/app/models/common.py b/api/app/models/common.py new file mode 100644 index 0000000..39b10cd --- /dev/null +++ b/api/app/models/common.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class Meta(BaseModel): + total: int = 0 + page: int = 1 + limit: int = 50 + pages: int = 0 + + +class PaginatedResponse(BaseModel, Generic[T]): + data: list[T] + meta: Meta + + +class ErrorResponse(BaseModel): + error: str + detail: str | None = None + code: str | None = None + + +class OkResponse(BaseModel): + ok: bool = True + + +class ArchivedResponse(BaseModel): + ok: bool = True + archived: bool + + +class HealthResponse(BaseModel): + status: str = "ok" + timestamp: str + database: str = "connected" + + +class APIInfoResponse(BaseModel): + name: str = "Cotizador E3 API" + version: str = "1.0.0" + description: str = "API para sistema de cotizaciones de marketing digital" + capabilities: list[str] = Field(default_factory=list) + + +class LoginRequest(BaseModel): + email: str + password: str + + +class LoginResponse(BaseModel): + user: dict[str, Any] + token: str | None = None diff --git a/api/app/models/configuracion.py b/api/app/models/configuracion.py new file mode 100644 index 0000000..de24ea5 --- /dev/null +++ b/api/app/models/configuracion.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + +ALLOWED_CONFIG_KEYS: set[str] = { + "color_primario", + "color_secundario", + "logo_base64", + "razon_social", + "rfc", + "domicilio_fiscal", + "cuenta_nacional", + "clabe_interbancaria", + "cuenta_internacional", + "cuenta_internacional_swift", + "hora_centinela", + "anualidad_hosting", + "iva", + "terminos_condiciones", + "no_incluye", + "notas_adicionales", +} + + +class ConfigUpdate(BaseModel): + config: dict[str, str] = Field( + ..., + description="Key-value config pairs. Only whitelisted keys are processed.", + json_schema_extra={ + "examples": [ + {"color_primario": "#ff0000", "razon_social": "Nueva Empresa SA de CV"} + ] + }, + ) + + def get_allowed(self) -> dict[str, str]: + return {k: v for k, v in self.config.items() if k in ALLOWED_CONFIG_KEYS} + + +class ConfigResponse(BaseModel): + config: dict[str, str] diff --git a/api/app/models/cotizacion.py b/api/app/models/cotizacion.py new file mode 100644 index 0000000..b31189f --- /dev/null +++ b/api/app/models/cotizacion.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class ServicioCotizadoInput(BaseModel): + catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo") + nombre: str = Field(..., min_length=1, description="Nombre del servicio") + fase: int = Field(..., ge=0, le=3) + tipoPago: str = Field(..., pattern="^(unico|mensual)$") + precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)") + tiempoEntrega: str + entregables: list[str] = Field(default_factory=list) + + +class PlanBucefaloInput(BaseModel): + nivel: str = Field(..., description="Nivel: basico, estandar, premium, empresarial") + precio: float = Field(..., ge=0) + + +class ClienteInput(BaseModel): + nombre: str = Field(..., min_length=1) + empresa: str = "" + email: str = "" + telefono: str = "" + + +class CotizacionCreate(BaseModel): + numero: str = Field(..., min_length=1, description="Número de cotización (formato UJ{YY}{MM}{init}{seq})") + fecha: datetime + vigencia: datetime + moneda: str = Field("MXN", pattern="^(MXN|USD)$") + tipoCambio: str = "NA" + proyecto: str = "MKT Digital" + esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$") + incluirBonos: bool = False + incluirFinanciamiento: bool = False + observaciones: str = "" + asesorId: str = Field(..., min_length=1) + cliente: ClienteInput + servicios: list[ServicioCotizadoInput] = Field(..., min_length=1) + planBucefalo: PlanBucefaloInput | None = None + + model_config = { + "json_schema_extra": { + "examples": [ + { + "numero": "UJ2605AG001", + "fecha": "2026-05-03", + "vigencia": "2026-05-24", + "moneda": "MXN", + "tipoCambio": "NA", + "proyecto": "MKT Digital", + "esquemaPago": "Pago Unico/Mensual", + "incluirBonos": False, + "incluirFinanciamiento": False, + "observaciones": "", + "asesorId": "cuid-asesor", + "cliente": { + "nombre": "Juan Pérez", + "empresa": "ACME Corp", + "email": "juan@acme.com", + "telefono": "4421234567", + }, + "servicios": [ + { + "catalogoId": "cuid-servicio", + "nombre": "SEO On-Page", + "fase": 3, + "tipoPago": "mensual", + "precio": 2900, + "tiempoEntrega": "7 - 14 dias", + "entregables": ["Keyword research"], + } + ], + "planBucefalo": {"nivel": "basico", "precio": 1000}, + } + ] + } + } + + +class CotizacionUpdate(BaseModel): + fecha: datetime | None = None + vigencia: datetime | None = None + moneda: str | None = Field(None, pattern="^(MXN|USD)$") + tipoCambio: str | None = None + proyecto: str | None = None + esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$") + incluirBonos: bool | None = None + incluirFinanciamiento: bool | None = None + observaciones: str | None = None + estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$") + cliente: ClienteInput | None = None + servicios: list[ServicioCotizadoInput] | None = None + planBucefalo: PlanBucefaloInput | None = None + + +class CambiarEstadoRequest(BaseModel): + estado: str = Field(..., pattern="^(borrador|enviada|aprobada|rechazada)$") + + +class ActualizarPrecioRequest(BaseModel): + servicioId: str = Field(..., min_length=1) + precio: float = Field(..., ge=0) + + +class CotizacionResponse(BaseModel): + id: str + numero: str + fecha: datetime + vigencia: datetime + moneda: str + tipoCambio: str + proyecto: str + esquemaPago: str + estado: str + incluirBonos: bool + incluirFinanciamiento: bool + observaciones: str | None + clienteId: str + asesorId: str + createdAt: datetime + updatedAt: datetime + cliente: dict[str, Any] | None = None + asesor: dict[str, Any] | None = None + servicios: list[dict[str, Any]] | None = None + planBucefalo: dict[str, Any] | None = None + + model_config = {"from_attributes": True} diff --git a/api/app/models/export_.py b/api/app/models/export_.py new file mode 100644 index 0000000..f2cb689 --- /dev/null +++ b/api/app/models/export_.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ServicioDraft(BaseModel): + nombre: str + fase: int + tipoPago: str + precio: float + tiempoEntrega: str + entregables: list[str] = Field(default_factory=list) + + +class ExportDraft(BaseModel): + clienteNombre: str = "" + clienteEmpresa: str = "" + asesorNombre: str = "" + fecha: str = "" + moneda: str = "MXN" + tipoCambio: str = "NA" + proyecto: str = "MKT Digital" + esquemaPago: str = "Pago Unico/Mensual" + incluirBonos: bool = False + servicios: list[ServicioDraft] = Field(default_factory=list) + planBucefaloNivel: str | None = None + observaciones: str = "" + + +class FinanciamientoRequest(BaseModel): + monto: float = Field(..., ge=0, description="Monto total a financiar en MXN") + meses: int = Field(..., ge=3, le=12, description="Plazo en meses: 3, 6, 9, o 12") + + +class FinanciamientoResponse(BaseModel): + pagoMensual: float + ivaMensual: float + totalMensual: float + comisionTotal: float + granTotal: float + meses: int + tasa: float + comision: float + + +class BonoResponse(BaseModel): + id: str + numero: int + titulo: str + descripcion: str + activo: bool + + +class PlanBucefaloResponse(BaseModel): + nivel: str + label: str + precio: float diff --git a/api/app/models/paquete.py b/api/app/models/paquete.py new file mode 100644 index 0000000..6cb8fdb --- /dev/null +++ b/api/app/models/paquete.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class FasePaqueteInput(BaseModel): + nombre: str = Field(..., min_length=1) + orden: int = Field(0, ge=0) + + +class PaqueteCreate(BaseModel): + nombre: str = Field(..., min_length=1, description="Nombre del paquete") + descripcion: str | None = None + fases: list[FasePaqueteInput] = Field(default_factory=list) + + +class PaqueteUpdate(BaseModel): + nombre: str | None = Field(None, min_length=1) + descripcion: str | None = None + activo: bool | None = None + + +class ManageAction(BaseModel): + action: str = Field(..., description="Acción: addFase, updateFase, deleteFase, addServicio, removeServicio") + faseId: str | None = None + nombre: str | None = None + orden: int | None = None + servicioCatalogoId: str | None = None + fasePaqueteId: str | None = None + + +class FasePaqueteResponse(BaseModel): + id: str + paqueteId: str + nombre: str + orden: int + createdAt: datetime + updatedAt: datetime + servicios: list[dict] | None = None + + model_config = {"from_attributes": True} + + +class PaqueteResponse(BaseModel): + id: str + nombre: str + descripcion: str | None + activo: bool + createdAt: datetime + updatedAt: datetime + fases: list[FasePaqueteResponse] | None = None + + model_config = {"from_attributes": True} diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py new file mode 100644 index 0000000..9f96d51 --- /dev/null +++ b/api/app/routers/auth.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.database import get_pool +from app.auth import verify_password, create_access_token, require_auth, get_password_hash +from app.models.common import LoginRequest, LoginResponse, OkResponse, ErrorResponse + +router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@router.post("/login", response_model=LoginResponse) +async def login(body: LoginRequest): + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + 'SELECT id, email, password, name, role FROM "User" WHERE email = $1', + body.email, + ) + + if not row: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciales inválidas", + ) + + if not verify_password(body.password, row["password"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciales inválidas", + ) + + token = create_access_token({"sub": str(row["id"]), "email": row["email"], "role": row["role"]}) + + return LoginResponse( + access_token=token, + user={ + "id": row["id"], + "email": row["email"], + "name": row["name"], + "role": row["role"], + }, + ) + + +@router.post("/logout", response_model=OkResponse) +async def logout(): + return OkResponse(ok=True) + + +@router.get("/me") +async def me(current_user: dict = Depends(require_auth)): + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + 'SELECT id, email, name, role, "createdAt", "updatedAt" FROM "User" WHERE id = $1', + current_user["sub"], + ) + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Usuario no encontrado", + ) + + return { + "id": row["id"], + "email": row["email"], + "name": row["name"], + "role": row["role"], + "createdAt": row["createdAt"].isoformat() if row["createdAt"] else None, + "updatedAt": row["updatedAt"].isoformat() if row["updatedAt"] else None, + } diff --git a/api/app/routers/bonos.py b/api/app/routers/bonos.py new file mode 100644 index 0000000..41a5c0f --- /dev/null +++ b/api/app/routers/bonos.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.auth import require_auth +from app.models.export_ import BonoResponse +from app.services.calculators import BONOS + +router = APIRouter(prefix="/bonos", tags=["Bonos"]) + + +@router.get("", response_model=list[BonoResponse]) +async def get_bonos(_auth: dict = Depends(require_auth)): + return [BonoResponse(**b) for b in BONOS] diff --git a/api/app/routers/catalogo.py b/api/app/routers/catalogo.py new file mode 100644 index 0000000..f905e4e --- /dev/null +++ b/api/app/routers/catalogo.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from asyncpg import Connection + +from app.auth import require_auth +from app.dependencies import get_db +from app.models.catalogo import ( + ServicioCatalogoCreate, + ServicioCatalogoUpdate, + ServicioCatalogoResponse, + CategoriaResponse, +) +from app.models.common import ArchivedResponse, ErrorResponse, OkResponse + +router = APIRouter(prefix="/catalogo", tags=["Catalogo"]) + + +def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse: + entregables = row["entregablesDefault"] + if isinstance(entregables, str): + entregables = json.loads(entregables) + + categoria = None + if categoria_row: + categoria = CategoriaResponse( + id=categoria_row["id"], + nombre=categoria_row["nombre"], + descripcion=categoria_row["descripcion"], + color=categoria_row["color"], + orden=categoria_row["orden"], + activo=categoria_row["activo"], + createdAt=categoria_row["createdAt"], + updatedAt=categoria_row["updatedAt"], + ) + + return ServicioCatalogoResponse( + id=row["id"], + nombre=row["nombre"], + descripcion=row["descripcion"], + fase=row["fase"], + tipoPago=row["tipoPago"], + precioBase=float(row["precioBase"]), + tiempoEntrega=row["tiempoEntrega"], + entregablesDefault=entregables, + categoriaId=row["categoriaId"], + variante=row["variante"], + activo=row["activo"], + orden=row["orden"], + createdAt=row["createdAt"], + updatedAt=row["updatedAt"], + categoriaRel=categoria, + ) + + +@router.get( + "", + response_model=list[ServicioCatalogoResponse], + summary="List active catalog services with filters", +) +async def list_catalogo( + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), + fase: int | None = Query(None, ge=0, le=3), + tipoPago: str | None = Query(None, pattern="^(unico|mensual)$"), + categoriaId: str | None = Query(None), + q: str | None = Query(None, description="Search by nombre or descripcion"), +): + where_clauses: list[str] = ['activo = true'] + params: list = [] + idx = 1 + + if fase is not None: + where_clauses.append(f"fase = ${idx}") + params.append(fase) + idx += 1 + + if tipoPago: + where_clauses.append(f'"tipoPago" = ${idx}') + params.append(tipoPago) + idx += 1 + + if categoriaId: + where_clauses.append(f'"categoriaId" = ${idx}') + params.append(categoriaId) + idx += 1 + + if q: + where_clauses.append(f"(nombre ILIKE ${idx} OR descripcion ILIKE ${idx})") + params.append(f"%{q}%") + idx += 1 + + where_sql = f"WHERE {' AND '.join(where_clauses)}" + + rows = await db.fetch( + f'SELECT * FROM "ServicioCatalogo" {where_sql} ORDER BY fase ASC, orden ASC', + *params, + ) + + result: list[ServicioCatalogoResponse] = [] + for r in rows: + cat_row = None + if r["categoriaId"]: + cat_row = await db.fetchrow( + 'SELECT * FROM "Categoria" WHERE id = $1', r["categoriaId"] + ) + result.append(_row_to_response(r, cat_row)) + + return result + + +@router.post( + "", + response_model=ServicioCatalogoResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new catalog service", +) +async def create_catalogo( + body: ServicioCatalogoCreate, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + if body.categoriaId: + cat = await db.fetchrow( + 'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId + ) + if not cat: + raise HTTPException(status_code=400, detail="Categoria no encontrada") + + entregables_json = json.dumps(body.entregablesDefault) + + row = await db.fetchrow( + 'INSERT INTO "ServicioCatalogo" ' + '(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", ' + '"entregablesDefault", "categoriaId", variante, activo, orden) ' + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10) RETURNING *", + body.nombre, + body.descripcion, + body.fase, + body.tipoPago, + body.precioBase, + body.tiempoEntrega, + entregables_json, + body.categoriaId, + body.variante, + body.orden, + ) + + cat_row = None + if row["categoriaId"]: + cat_row = await db.fetchrow( + 'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"] + ) + + return _row_to_response(row, cat_row) + + +@router.get( + "/{servicio_id}", + response_model=ServicioCatalogoResponse, + responses={404: {"model": ErrorResponse}}, + summary="Get catalog service by ID with categoria join", +) +async def get_catalogo( + servicio_id: str, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + row = await db.fetchrow( + 'SELECT * FROM "ServicioCatalogo" WHERE id = $1', servicio_id + ) + if not row: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + + cat_row = None + if row["categoriaId"]: + cat_row = await db.fetchrow( + 'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"] + ) + + return _row_to_response(row, cat_row) + + +@router.put( + "/{servicio_id}", + response_model=ServicioCatalogoResponse, + responses={404: {"model": ErrorResponse}}, + summary="Update a catalog service", +) +async def update_catalogo( + servicio_id: str, + body: ServicioCatalogoUpdate, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + existing = await db.fetchrow( + 'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id + ) + if not existing: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + + if body.categoriaId: + cat = await db.fetchrow( + 'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId + ) + if not cat: + raise HTTPException(status_code=400, detail="Categoria no encontrada") + + field_map = { + "nombre": "nombre", + "descripcion": "descripcion", + "fase": "fase", + "tipoPago": '"tipoPago"', + "precioBase": '"precioBase"', + "tiempoEntrega": '"tiempoEntrega"', + "entregablesDefault": '"entregablesDefault"', + "categoriaId": '"categoriaId"', + "variante": "variante", + "activo": "activo", + "orden": "orden", + } + + updates: list[str] = [] + params: list = [] + idx = 1 + + for field, col in field_map.items(): + value = getattr(body, field, None) + if value is not None: + if field == "entregablesDefault": + value = json.dumps(value) + updates.append(f"{col} = ${idx}") + params.append(value) + idx += 1 + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + params.append(servicio_id) + row = await db.fetchrow( + f'UPDATE "ServicioCatalogo" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *', + *params, + ) + + cat_row = None + if row["categoriaId"]: + cat_row = await db.fetchrow( + 'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"] + ) + + return _row_to_response(row, cat_row) + + +@router.delete( + "/{servicio_id}", + response_model=ArchivedResponse, + responses={404: {"model": ErrorResponse}}, + summary="Delete or soft-delete a catalog service", +) +async def delete_catalogo( + servicio_id: str, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + existing = await db.fetchrow( + 'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id + ) + if not existing: + raise HTTPException(status_code=404, detail="Servicio no encontrado") + + ref_count = await db.fetchval( + 'SELECT COUNT(*) FROM "ServicioCotizado" WHERE "servicioCatalogoId" = $1', + servicio_id, + ) + + if ref_count > 0: + await db.execute( + 'UPDATE "ServicioCatalogo" SET activo = false WHERE id = $1', servicio_id + ) + return ArchivedResponse(ok=True, archived=True) + + await db.execute('DELETE FROM "ServicioCatalogo" WHERE id = $1', servicio_id) + return ArchivedResponse(ok=True, archived=False) diff --git a/api/app/routers/categorias.py b/api/app/routers/categorias.py new file mode 100644 index 0000000..fa14b09 --- /dev/null +++ b/api/app/routers/categorias.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.database import get_pool +from app.dependencies import get_db +from app.auth import require_auth +from app.models.catalogo import CategoriaCreate, CategoriaUpdate, CategoriaResponse +from app.models.common import ErrorResponse, OkResponse + +router = APIRouter(prefix="/categorias", tags=["Categorias"]) + + +@router.get("", response_model=list[CategoriaResponse]) +async def list_categorias( + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + rows = await conn.fetch( + 'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"' + ' FROM "Categoria" ORDER BY orden ASC' + ) + return [dict(r) for r in rows] + + +@router.post( + "", + response_model=CategoriaResponse, + status_code=status.HTTP_201_CREATED, + responses={409: {"model": ErrorResponse}}, +) +async def create_categoria( + body: CategoriaCreate, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + now = datetime.now(timezone.utc) + try: + row = await conn.fetchrow( + 'INSERT INTO "Categoria" (id, nombre, descripcion, color, orden, "createdAt", "updatedAt")' + " VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $5)" + ' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"', + body.nombre, + body.descripcion, + body.color, + body.orden, + now, + ) + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Ya existe una categoría con el nombre '{body.nombre}'", + ) + raise + return dict(row) + + +@router.get( + "/{categoria_id}", + responses={404: {"model": ErrorResponse}}, +) +async def get_categoria( + categoria_id: str, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + row = await conn.fetchrow( + 'SELECT c.id, c.nombre, c.descripcion, c.color, c.activo, c.orden,' + ' c."createdAt", c."updatedAt",' + ' COUNT(s.id)::int AS "serviciosCount"' + ' FROM "Categoria" c' + ' LEFT JOIN "ServicioCatalogo" s ON s."categoriaId" = c.id' + " WHERE c.id = $1" + " GROUP BY c.id", + categoria_id, + ) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Categoría no encontrada", + ) + return dict(row) + + +@router.put( + "/{categoria_id}", + response_model=CategoriaResponse, + responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}}, +) +async def update_categoria( + categoria_id: str, + body: CategoriaUpdate, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + existing = await conn.fetchrow( + 'SELECT id FROM "Categoria" WHERE id = $1', categoria_id + ) + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Categoría no encontrada", + ) + + updates: list[str] = [] + params: list = [] + idx = 1 + + if body.nombre is not None: + updates.append(f"nombre = ${idx}") + params.append(body.nombre) + idx += 1 + if body.descripcion is not None: + updates.append(f"descripcion = ${idx}") + params.append(body.descripcion) + idx += 1 + if body.color is not None: + updates.append(f"color = ${idx}") + params.append(body.color) + idx += 1 + if body.orden is not None: + updates.append(f"orden = ${idx}") + params.append(body.orden) + idx += 1 + if body.activo is not None: + updates.append(f"activo = ${idx}") + params.append(body.activo) + idx += 1 + + if not updates: + row = await conn.fetchrow( + 'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"' + ' FROM "Categoria" WHERE id = $1', + categoria_id, + ) + return dict(row) + + updates.append(f'"updatedAt" = ${idx}') + params.append(datetime.now(timezone.utc)) + idx += 1 + + params.append(categoria_id) + try: + row = await conn.fetchrow( + f'UPDATE "Categoria" SET {", ".join(updates)} WHERE id = ${idx}' + ' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"', + *params, + ) + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Ya existe una categoría con el nombre '{body.nombre}'", + ) + raise + return dict(row) + + +@router.delete( + "/{categoria_id}", + response_model=OkResponse, + responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}}, +) +async def delete_categoria( + categoria_id: str, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + existing = await conn.fetchrow( + 'SELECT id FROM "Categoria" WHERE id = $1', categoria_id + ) + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Categoría no encontrada", + ) + + count = await conn.fetchval( + 'SELECT COUNT(*)::int FROM "ServicioCatalogo" WHERE "categoriaId" = $1', + categoria_id, + ) + if count > 0: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Tiene {count} servicios asociados", + ) + + await conn.execute('DELETE FROM "Categoria" WHERE id = $1', categoria_id) + return OkResponse(ok=True) diff --git a/api/app/routers/clientes.py b/api/app/routers/clientes.py new file mode 100644 index 0000000..fb26812 --- /dev/null +++ b/api/app/routers/clientes.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import math + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from asyncpg import Connection + +from app.auth import require_auth +from app.dependencies import PaginationParams, get_db +from app.models.cliente import ClienteCreate, ClienteUpdate, ClienteResponse +from app.models.common import PaginatedResponse, Meta, ErrorResponse, OkResponse + +router = APIRouter(prefix="/clientes", tags=["Clientes"]) + + +@router.get( + "", + response_model=PaginatedResponse[ClienteResponse], + summary="List clients with pagination and search", +) +async def list_clientes( + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), + pagination: PaginationParams = Depends(PaginationParams), + q: str | None = Query(None, description="Search by nombre, empresa, or email"), +): + where_clauses: list[str] = [] + params: list = [] + idx = 1 + + if q: + where_clauses.append( + f"(nombre ILIKE ${idx} OR empresa ILIKE ${idx} OR email ILIKE ${idx})" + ) + params.append(f"%{q}%") + idx += 1 + + where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + count_row = await db.fetchrow( + f"SELECT COUNT(*) AS total FROM \"Cliente\" {where_sql}", *params + ) + total = count_row["total"] + pages = math.ceil(total / pagination.limit) if total > 0 else 0 + + rows = await db.fetch( + f'SELECT * FROM "Cliente" {where_sql} ORDER BY "createdAt" DESC ' + f"LIMIT ${idx} OFFSET ${idx + 1}", + *params, + pagination.limit, + pagination.offset, + ) + + data = [ + ClienteResponse( + id=r["id"], + nombre=r["nombre"], + empresa=r["empresa"], + email=r["email"], + telefono=r["telefono"], + createdAt=r["createdAt"], + updatedAt=r["updatedAt"], + ) + for r in rows + ] + + return PaginatedResponse( + data=data, + meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages), + ) + + +@router.post( + "", + response_model=ClienteResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new client", +) +async def create_cliente( + body: ClienteCreate, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + row = await db.fetchrow( + 'INSERT INTO "Cliente" (nombre, empresa, email, telefono) ' + "VALUES ($1, $2, $3, $4) RETURNING *", + body.nombre, + body.empresa, + body.email, + body.telefono, + ) + return ClienteResponse( + id=row["id"], + nombre=row["nombre"], + empresa=row["empresa"], + email=row["email"], + telefono=row["telefono"], + createdAt=row["createdAt"], + updatedAt=row["updatedAt"], + ) + + +@router.get( + "/{cliente_id}", + response_model=ClienteResponse, + responses={404: {"model": ErrorResponse}}, + summary="Get client by ID with recent cotizaciones", +) +async def get_cliente( + cliente_id: str, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + row = await db.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cliente_id) + if not row: + raise HTTPException(status_code=404, detail="Cliente no encontrado") + + cotizaciones = await db.fetch( + 'SELECT * FROM "Cotizacion" WHERE "clienteId" = $1 ' + 'ORDER BY "createdAt" DESC LIMIT 10', + cliente_id, + ) + + cliente = ClienteResponse( + id=row["id"], + nombre=row["nombre"], + empresa=row["empresa"], + email=row["email"], + telefono=row["telefono"], + createdAt=row["createdAt"], + updatedAt=row["updatedAt"], + ) + + return cliente.model_copy( + update={"cotizaciones": [dict(c) for c in cotizaciones]} + ) + + +@router.put( + "/{cliente_id}", + response_model=ClienteResponse, + responses={404: {"model": ErrorResponse}}, + summary="Update a client", +) +async def update_cliente( + cliente_id: str, + body: ClienteUpdate, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id) + if not existing: + raise HTTPException(status_code=404, detail="Cliente no encontrado") + + updates: list[str] = [] + params: list = [] + idx = 1 + + for field in ("nombre", "empresa", "email", "telefono"): + value = getattr(body, field, None) + if value is not None: + updates.append(f"{field} = ${idx}") + params.append(value) + idx += 1 + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + params.append(cliente_id) + row = await db.fetchrow( + f'UPDATE "Cliente" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *', + *params, + ) + + return ClienteResponse( + id=row["id"], + nombre=row["nombre"], + empresa=row["empresa"], + email=row["email"], + telefono=row["telefono"], + createdAt=row["createdAt"], + updatedAt=row["updatedAt"], + ) + + +@router.delete( + "/{cliente_id}", + response_model=OkResponse, + responses={409: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}, + summary="Delete a client (fails if cotizaciones exist)", +) +async def delete_cliente( + cliente_id: str, + db: Connection = Depends(get_db), + auth: dict = Depends(require_auth), +): + existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id) + if not existing: + raise HTTPException(status_code=404, detail="Cliente no encontrado") + + cot_count = await db.fetchval( + 'SELECT COUNT(*) FROM "Cotizacion" WHERE "clienteId" = $1', cliente_id + ) + if cot_count > 0: + raise HTTPException( + status_code=409, + detail="No se puede eliminar: el cliente tiene cotizaciones asociadas", + ) + + await db.execute('DELETE FROM "Cliente" WHERE id = $1', cliente_id) + return OkResponse() diff --git a/api/app/routers/configuracion.py b/api/app/routers/configuracion.py new file mode 100644 index 0000000..66d2cf1 --- /dev/null +++ b/api/app/routers/configuracion.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from asyncpg import Connection + +from app.dependencies import get_db +from app.models.configuracion import ConfigUpdate, ConfigResponse, ALLOWED_CONFIG_KEYS +from app.models.common import ErrorResponse, OkResponse +from app.auth import require_auth + +router = APIRouter(prefix="/configuracion", tags=["Configuración"]) + + +@router.get("", response_model=ConfigResponse) +async def get_configuracion( + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + rows = await db.fetch("SELECT clave, valor FROM Configuracion") + config = {row["clave"]: row["valor"] for row in rows} + return ConfigResponse(config=config) + + +@router.put("", response_model=OkResponse, responses={400: {"model": ErrorResponse}}) +async def update_configuracion( + body: ConfigUpdate, + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + allowed = body.get_allowed() + if not allowed: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No valid config keys provided", + ) + + async with db.transaction(): + for clave, valor in allowed.items(): + await db.execute( + """ + INSERT INTO "Configuracion" (id, clave, valor, "updatedAt") + VALUES (gen_random_uuid(), $1, $2, now()) + ON CONFLICT (clave) DO UPDATE SET valor = $2, "updatedAt" = now() + """, + clave, + valor, + ) + + return OkResponse() diff --git a/api/app/routers/cotizaciones.py b/api/app/routers/cotizaciones.py new file mode 100644 index 0000000..d8c0a20 --- /dev/null +++ b/api/app/routers/cotizaciones.py @@ -0,0 +1,565 @@ +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.auth import require_auth +from app.database import get_pool +from app.dependencies import PaginationParams, parse_date +from app.models.common import Meta, OkResponse, PaginatedResponse +from app.models.cotizacion import ( + ActualizarPrecioRequest, + CambiarEstadoRequest, + CotizacionCreate, + CotizacionResponse, + CotizacionUpdate, +) +from app.services.calculators import ESTADOS_COTIZACION + +router = APIRouter(prefix="/cotizaciones", tags=["Cotizaciones"]) + + +def _cuid() -> str: + return uuid.uuid4().hex[:25] + + +def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict: + d = dict(cot) + d["cliente"] = dict(cliente) if cliente else None + d["asesor"] = dict(asesor) if asesor else None + d["servicios"] = [dict(s) for s in servicios] if servicios else [] + d["planBucefalo"] = dict(plan) if plan else None + return d + + +async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None: + cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) + if not cot: + return None + + cliente = await conn.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cot["clienteId"]) + asesor = await conn.fetchrow('SELECT id, email, name, role FROM "User" WHERE id = $1', cot["asesorId"]) + servicios = await conn.fetch( + """ + SELECT sc.*, sc."servicioCatalogoId" as "catalogoId", + s.nombre, s.descripcion, s."categoriaId" + FROM "ServicioCotizado" sc + JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId" + WHERE sc."cotizacionId" = $1 + ORDER BY sc.fase, sc.id + """, + cot_id, + ) + plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id) + + return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan) + + +async def _resolve_bucefalo_servicio(conn) -> str | None: + row = await conn.fetchrow( + """ + SELECT s.id FROM "ServicioCatalogo" s + JOIN "Categoria" c ON c.id = s."categoriaId" + WHERE c.nombre = 'CRM' AND s."tipoPago" = 'mensual' AND s.activo = true + ORDER BY s.orden LIMIT 1 + """ + ) + return row["id"] if row else None + + +async def _find_or_create_cliente(conn, nombre: str, empresa: str, email: str, telefono: str) -> str: + row = await conn.fetchrow( + 'SELECT id FROM "Cliente" WHERE nombre = $1 AND COALESCE(empresa, \'\') = $2', + nombre, + empresa or "", + ) + if row: + if email or telefono: + await conn.execute( + 'UPDATE "Cliente" SET email = COALESCE($1, email), telefono = COALESCE($2, telefono), "updatedAt" = NOW() WHERE id = $3', + email or None, + telefono or None, + row["id"], + ) + return row["id"] + + new_id = _cuid() + await conn.execute( + 'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, $5, NOW(), NOW())', + new_id, + nombre, + empresa or None, + email or None, + telefono or None, + ) + return new_id + + +# --------------------------------------------------------------------------- +# 1. GET /cotizaciones — List with filters + pagination +# --------------------------------------------------------------------------- +@router.get("", response_model=PaginatedResponse[CotizacionResponse]) +async def list_cotizaciones( + pagination: PaginationParams = Depends(), + estado: str | None = Query(None), + asesorId: str | None = Query(None), + clienteId: str | None = Query(None), + q: str | None = Query(None), + desde: str | None = Query(None), + hasta: str | None = Query(None), + _auth: dict = Depends(require_auth), +): + pool = await get_pool() + + conditions: list[str] = [] + params: list = [] + idx = 1 + + if estado: + conditions.append(f'c.estado = ${idx}') + params.append(estado) + idx += 1 + if asesorId: + conditions.append(f'c."asesorId" = ${idx}') + params.append(asesorId) + idx += 1 + if clienteId: + conditions.append(f'c."clienteId" = ${idx}') + params.append(clienteId) + idx += 1 + if q: + conditions.append( + f'(c.numero ILIKE ${idx} OR cl.nombre ILIKE ${idx} OR c.proyecto ILIKE ${idx})' + ) + params.append(f"%{q}%") + idx += 1 + desde_dt = parse_date(desde) + if desde_dt: + conditions.append(f'c.fecha >= ${idx}') + params.append(desde_dt) + idx += 1 + hasta_dt = parse_date(hasta) + if hasta_dt: + conditions.append(f'c.fecha <= ${idx}') + params.append(hasta_dt) + idx += 1 + + where = "WHERE " + " AND ".join(conditions) if conditions else "" + + count_row = await pool.fetchrow( + f'SELECT COUNT(*) FROM "Cotizacion" c LEFT JOIN "Cliente" cl ON cl.id = c."clienteId" {where}', + *params, + ) + total = count_row["count"] + + rows = await pool.fetch( + f""" + SELECT c.* FROM "Cotizacion" c + LEFT JOIN "Cliente" cl ON cl.id = c."clienteId" + {where} + ORDER BY c."createdAt" DESC + LIMIT ${idx} OFFSET ${idx + 1} + """, + *params, + pagination.limit, + pagination.offset, + ) + + cotizaciones = [] + for r in rows: + full = await _fetch_cotizacion_full(pool, r["id"]) + if full: + cotizaciones.append(full) + + pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0 + + return PaginatedResponse( + data=cotizaciones, + meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages), + ) + + +# --------------------------------------------------------------------------- +# 2. POST /cotizaciones — Create +# --------------------------------------------------------------------------- +@router.post("", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED) +async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(require_auth)): + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.transaction(): + cliente_id = await _find_or_create_cliente( + conn, + body.cliente.nombre, + body.cliente.empresa, + body.cliente.email, + body.cliente.telefono, + ) + + cot_id = _cuid() + now = datetime.now(timezone.utc) + await conn.execute( + """ + INSERT INTO "Cotizacion" + (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", + estado, "incluirBonos", "incluirFinanciamiento", observaciones, + "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14) + """, + cot_id, + body.numero, + body.fecha, + body.vigencia, + body.moneda, + body.tipoCambio, + body.proyecto, + body.esquemaPago, + body.incluirBonos, + body.incluirFinanciamiento, + body.observaciones or None, + cliente_id, + body.asesorId, + now, + ) + + bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn) + + for svc in body.servicios: + catalogo_id = svc.catalogoId + if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id: + catalogo_id = bucefalo_servicio_id + + await conn.execute( + """ + INSERT INTO "ServicioCotizado" + (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio, + "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10) + """, + _cuid(), + cot_id, + catalogo_id, + svc.fase, + svc.tipoPago, + svc.precio, + svc.tiempoEntrega, + json.dumps(svc.entregables), + None, + now, + ) + + if body.planBucefalo: + await conn.execute( + """ + INSERT INTO "PlanBucefaloCotizacion" + (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,true,$5,$5) + """, + _cuid(), + cot_id, + body.planBucefalo.nivel, + body.planBucefalo.precio, + now, + ) + + row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) + return dict(row) + + +# --------------------------------------------------------------------------- +# 3. GET /cotizaciones/{id} — Get with relations +# --------------------------------------------------------------------------- +@router.get("/{cotizacion_id}", response_model=CotizacionResponse) +async def get_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)): + pool = await get_pool() + full = await _fetch_cotizacion_full(pool, cotizacion_id) + if not full: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + return full + + +# --------------------------------------------------------------------------- +# 4. PUT /cotizaciones/{id} — Update +# --------------------------------------------------------------------------- +@router.put("/{cotizacion_id}", response_model=CotizacionResponse) +async def update_cotizacion( + cotizacion_id: str, + body: CotizacionUpdate, + _auth: dict = Depends(require_auth), +): + pool = await get_pool() + async with pool.acquire() as conn: + existing = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id) + if not existing: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + + async with conn.transaction(): + cliente_id = existing["clienteId"] + if body.cliente: + cliente_id = await _find_or_create_cliente( + conn, + body.cliente.nombre, + body.cliente.empresa, + body.cliente.email, + body.cliente.telefono, + ) + + updates: list[str] = [] + params: list = [] + idx = 1 + + def _add(field: str, col: str, val): + nonlocal idx + if val is not None: + updates.append(f'{col} = ${idx}') + params.append(val) + idx += 1 + + _add("fecha", "fecha", body.fecha) + _add("vigencia", "vigencia", body.vigencia) + _add("moneda", "moneda", body.moneda) + _add("tipoCambio", '"tipoCambio"', body.tipoCambio) + _add("proyecto", "proyecto", body.proyecto) + _add("esquemaPago", '"esquemaPago"', body.esquemaPago) + _add("incluirBonos", '"incluirBonos"', body.incluirBonos) + _add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento) + _add("observaciones", "observaciones", body.observaciones) + _add("estado", "estado", body.estado) + + if cliente_id != existing["clienteId"]: + updates.append(f'"clienteId" = ${idx}') + params.append(cliente_id) + idx += 1 + + if updates: + updates.append(f'"updatedAt" = ${idx}') + params.append(datetime.now(timezone.utc)) + idx += 1 + params.append(cotizacion_id) + await conn.execute( + f'UPDATE "Cotizacion" SET {", ".join(updates)} WHERE id = ${idx}', + *params, + ) + + if body.servicios is not None: + await conn.execute('DELETE FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cotizacion_id) + bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn) + now = datetime.now(timezone.utc) + for svc in body.servicios: + catalogo_id = svc.catalogoId + if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id: + catalogo_id = bucefalo_servicio_id + await conn.execute( + """ + INSERT INTO "ServicioCotizado" + (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio, + "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10) + """, + _cuid(), + cotizacion_id, + catalogo_id, + svc.fase, + svc.tipoPago, + svc.precio, + svc.tiempoEntrega, + json.dumps(svc.entregables), + None, + now, + ) + + if body.planBucefalo is not None: + existing_plan = await conn.fetchrow( + 'SELECT id FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', + cotizacion_id, + ) + now = datetime.now(timezone.utc) + if existing_plan: + await conn.execute( + 'UPDATE "PlanBucefaloCotizacion" SET nivel = $1, precio = $2, "updatedAt" = $3 WHERE id = $4', + body.planBucefalo.nivel, + body.planBucefalo.precio, + now, + existing_plan["id"], + ) + else: + await conn.execute( + """ + INSERT INTO "PlanBucefaloCotizacion" + (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,true,$5,$5) + """, + _cuid(), + cotizacion_id, + body.planBucefalo.nivel, + body.planBucefalo.precio, + now, + ) + elif body.planBucefalo is None and "planBucefalo" in body.model_fields_set: + await conn.execute( + 'DELETE FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', + cotizacion_id, + ) + + full = await _fetch_cotizacion_full(pool, cotizacion_id) + return full + + +# --------------------------------------------------------------------------- +# 5. DELETE /cotizaciones/{id} +# --------------------------------------------------------------------------- +@router.delete("/{cotizacion_id}", response_model=OkResponse) +async def delete_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)): + pool = await get_pool() + existing = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id) + if not existing: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + await pool.execute('DELETE FROM "Cotizacion" WHERE id = $1', cotizacion_id) + return OkResponse(ok=True) + + +# --------------------------------------------------------------------------- +# 6. PATCH /cotizaciones/{id}/precio +# --------------------------------------------------------------------------- +@router.patch("/{cotizacion_id}/precio", response_model=OkResponse) +async def update_precio_servicio( + cotizacion_id: str, + body: ActualizarPrecioRequest, + _auth: dict = Depends(require_auth), +): + pool = await get_pool() + cot = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id) + if not cot: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + + svc = await pool.fetchrow( + 'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2', + body.servicioId, + cotizacion_id, + ) + if not svc: + raise HTTPException(status_code=404, detail="Servicio no pertenece a esta cotización") + + await pool.execute( + 'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2', + body.precio, + body.servicioId, + ) + return OkResponse(ok=True) + + +# --------------------------------------------------------------------------- +# 7. PATCH /cotizaciones/{id}/estado +# --------------------------------------------------------------------------- +@router.patch("/{cotizacion_id}/estado", response_model=OkResponse) +async def cambiar_estado( + cotizacion_id: str, + body: CambiarEstadoRequest, + _auth: dict = Depends(require_auth), +): + if body.estado not in ESTADOS_COTIZACION: + raise HTTPException( + status_code=422, + detail=f"Estado inválido. Permitidos: {', '.join(ESTADOS_COTIZACION)}", + ) + + pool = await get_pool() + result = await pool.execute( + 'UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', + body.estado, + cotizacion_id, + ) + if result == "UPDATE 0": + raise HTTPException(status_code=404, detail="Cotización no encontrada") + return OkResponse(ok=True) + + +# --------------------------------------------------------------------------- +# 8. POST /cotizaciones/{id}/duplicate +# --------------------------------------------------------------------------- +@router.post("/{cotizacion_id}/duplicate", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED) +async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)): + pool = await get_pool() + async with pool.acquire() as conn: + original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id) + if not original: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + + servicios = await conn.fetch( + 'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', + cotizacion_id, + ) + plan = await conn.fetchrow( + 'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', + cotizacion_id, + ) + + async with conn.transaction(): + now = datetime.now(timezone.utc) + new_id = _cuid() + new_numero = original["numero"] + "-COPY" + + await conn.execute( + """ + INSERT INTO "Cotizacion" + (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", + estado, "incluirBonos", "incluirFinanciamiento", observaciones, + "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14) + """, + new_id, + new_numero, + original["fecha"], + original["vigencia"], + original["moneda"], + original["tipoCambio"], + original["proyecto"], + original["esquemaPago"], + original["incluirBonos"], + original["incluirFinanciamiento"], + original["observaciones"], + original["clienteId"], + original["asesorId"], + now, + ) + + for svc in servicios: + await conn.execute( + """ + INSERT INTO "ServicioCotizado" + (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio, + "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11) + """, + _cuid(), + new_id, + svc["servicioCatalogoId"], + svc["fase"], + svc["tipoPago"], + svc["precio"], + svc["tiempoEntrega"], + svc["entregables"], + svc["notas"], + svc["seleccionado"], + now, + ) + + if plan: + await conn.execute( + """ + INSERT INTO "PlanBucefaloCotizacion" + (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$6) + """, + _cuid(), + new_id, + plan["nivel"], + plan["precio"], + plan["seleccionado"], + now, + ) + + full = await _fetch_cotizacion_full(pool, new_id) + return full diff --git a/api/app/routers/export_.py b/api/app/routers/export_.py new file mode 100644 index 0000000..8849764 --- /dev/null +++ b/api/app/routers/export_.py @@ -0,0 +1,331 @@ +"""Export endpoints — PDF, Excel, CSV generation.""" + +from __future__ import annotations + +import csv +import io +import json +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import PlainTextResponse, Response +from asyncpg import Connection + +from app.dependencies import get_db +from app.models.export_ import ExportDraft +from app.models.common import ErrorResponse +from app.auth import require_auth +from app.services.calculators import bucefalo_precio, calcular_vigencia, sanitize_filename +from app.services.pdf_generator import generate_cotizacion_pdf +from app.services.excel_generator import generate_cotizacion_excel + +router = APIRouter(prefix="/export", tags=["Export"]) + + +async def _load_branding(db: Connection) -> dict[str, str]: + rows = await db.fetch('SELECT clave, valor FROM "Configuracion"') + return {r["clave"]: r["valor"] for r in rows} + + +def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) -> dict[str, Any]: + fecha = datetime.now() + if draft.fecha: + try: + fecha = datetime.fromisoformat(draft.fecha.replace("Z", "+00:00")) + except ValueError: + try: + fecha = datetime.strptime(draft.fecha, "%Y-%m-%d") + except ValueError: + pass + + vigencia = calcular_vigencia(fecha) + logo_raw = branding.get("logo_base64", "") + logo_mime = None + logo_b64 = None + if logo_raw and ":" in logo_raw: + parts = logo_raw.split(":", 1) + logo_mime = parts[0] + logo_b64 = parts[1] + elif logo_raw: + logo_b64 = logo_raw + + return { + "numero": "BORRADOR", + "clienteNombre": draft.clienteNombre, + "clienteEmpresa": draft.clienteEmpresa, + "asesorNombre": draft.asesorNombre, + "fecha": fecha, + "vigencia": vigencia, + "moneda": draft.moneda, + "tipoCambio": draft.tipoCambio, + "proyecto": draft.proyecto, + "esquemaPago": draft.esquemaPago, + "servicios": [ + { + "nombre": s.nombre, + "fase": s.fase, + "tipoPago": s.tipoPago, + "precio": s.precio, + "tiempoEntrega": s.tiempoEntrega, + "entregables": s.entregables, + } + for s in draft.servicios + ], + "planBucefaloNivel": draft.planBucefaloNivel, + "planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0, + "incluirBonos": draft.incluirBonos, + "colorPrimario": branding.get("color_primario", "#2563eb"), + "colorSecundario": branding.get("color_secundario", "#1e293b"), + "logoBase64": logo_b64, + "logoMime": logo_mime, + "configBancaria": branding, + "razonSocial": branding.get("razon_social", "Cotizador E3"), + } + + +async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[str, Any] | None: + cot = await db.fetchrow( + """SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa, + u.name as asesor_nombre + FROM "Cotizacion" c + LEFT JOIN "Cliente" cl ON cl.id = c."clienteId" + LEFT JOIN "User" u ON u.id = c."asesorId" + WHERE c.id = $1""", + cotizacion_id, + ) + if not cot: + return None + + servicios = await db.fetch( + """SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables, + s.nombre + FROM "ServicioCotizado" sc + JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId" + WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true + ORDER BY sc.fase, sc.id""", + cotizacion_id, + ) + + plan = await db.fetchrow( + 'SELECT nivel, precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', + cotizacion_id, + ) + + branding = await _load_branding(db) + + logo_raw = branding.get("logo_base64", "") + logo_mime = None + logo_b64 = None + if logo_raw and ":" in logo_raw: + parts = logo_raw.split(":", 1) + logo_mime = parts[0] + logo_b64 = parts[1] + elif logo_raw: + logo_b64 = logo_raw + + servicios_list = [] + for s in servicios: + ent = s["entregables"] + if isinstance(ent, str): + try: + ent = json.loads(ent) + except (json.JSONDecodeError, TypeError): + ent = [] + servicios_list.append({ + "nombre": s["nombre"], + "fase": s["fase"], + "tipoPago": s["tipoPago"], + "precio": float(s["precio"]), + "tiempoEntrega": s["tiempoEntrega"], + "entregables": ent or [], + }) + + plan_nivel = plan["nivel"] if plan else None + + return { + "numero": cot["numero"], + "clienteNombre": cot["cliente_nombre"] or "", + "clienteEmpresa": cot["cliente_empresa"] or "", + "asesorNombre": cot["asesor_nombre"] or "", + "fecha": cot["fecha"], + "vigencia": cot["vigencia"], + "moneda": cot["moneda"], + "tipoCambio": cot["tipoCambio"], + "proyecto": cot["proyecto"], + "esquemaPago": cot["esquemaPago"], + "servicios": servicios_list, + "planBucefaloNivel": plan_nivel, + "planBucefaloPrecio": float(plan["precio"]) if plan else 0, + "incluirBonos": cot["incluirBonos"], + "colorPrimario": branding.get("color_primario", "#2563eb"), + "colorSecundario": branding.get("color_secundario", "#1e293b"), + "logoBase64": logo_b64, + "logoMime": logo_mime, + "configBancaria": branding, + "razonSocial": branding.get("razon_social", "Cotizador E3"), + } + + +# ── PDF ENDPOINTS ────────────────────────────────── + +@router.post("/pdf") +async def export_pdf_draft( + body: ExportDraft, + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + branding = await _load_branding(db) + pdf_data = _build_pdf_data_from_draft(body, branding) + pdf_bytes = generate_cotizacion_pdf(pdf_data) + + empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion" + filename = f"{sanitize_filename(empresa)} - BORRADOR.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/pdf/{cotizacion_id}") +async def export_pdf_saved( + cotizacion_id: str, + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + pdf_data = await _build_pdf_data_from_db(db, cotizacion_id) + if not pdf_data: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + + pdf_bytes = generate_cotizacion_pdf(pdf_data) + + empresa = pdf_data["clienteEmpresa"] or pdf_data["clienteNombre"] + filename = f"{sanitize_filename(empresa)} - {sanitize_filename(pdf_data['numero'])}.pdf" + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ── EXCEL ENDPOINTS ──────────────────────────────── + +@router.post("/excel") +async def export_excel_draft( + body: ExportDraft, + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + branding = await _load_branding(db) + excel_data = _build_pdf_data_from_draft(body, branding) + excel_bytes = generate_cotizacion_excel(excel_data, saved=False) + + empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion" + filename = f"{sanitize_filename(empresa)} - {sanitize_filename(body.clienteNombre)} - BORRADOR.xlsx" + + return Response( + content=excel_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/excel/{cotizacion_id}") +async def export_excel_saved( + cotizacion_id: str, + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + excel_data = await _build_pdf_data_from_db(db, cotizacion_id) + if not excel_data: + raise HTTPException(status_code=404, detail="Cotización no encontrada") + + excel_bytes = generate_cotizacion_excel(excel_data, saved=True) + + empresa = excel_data["clienteEmpresa"] or excel_data["clienteNombre"] + filename = f"{sanitize_filename(empresa)} - {sanitize_filename(excel_data['numero'])}.xlsx" + + return Response( + content=excel_bytes, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# ── CSV ENDPOINTS ────────────────────────────────── + +@router.get("/catalogo", response_class=PlainTextResponse) +async def export_catalogo_csv( + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + rows = await db.fetch( + """SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago", + s."precioBase", s."tiempoEntrega", s."entregablesDefault", + s.variante, s.orden, c.nombre AS categoria + FROM "ServicioCatalogo" s + LEFT JOIN "Categoria" c ON c.id = s."categoriaId" + WHERE s.activo = true + ORDER BY s.fase, s.orden""" + ) + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([ + "Nombre", "Categoria", "Fase", "Tipo de Pago", "Precio Base", + "Tiempo de Entrega", "Entregables", "Variante", + ]) + for r in rows: + entregables = r["entregablesDefault"] or [] + if isinstance(entregables, list): + entregables = " | ".join(entregables) + elif isinstance(entregables, str): + try: + ent_list = json.loads(entregables) + entregables = " | ".join(ent_list) if isinstance(ent_list, list) else entregables + except (json.JSONDecodeError, TypeError): + pass + writer.writerow([ + r["nombre"], r["categoria"], r["fase"], r["tipoPago"], + r["precioBase"], r["tiempoEntrega"], entregables, r["variante"], + ]) + + return PlainTextResponse( + content=buf.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=catalogo-servicios.csv"}, + ) + + +@router.get("/catalogo/plantilla", response_class=PlainTextResponse) +async def export_catalogo_template(db: Connection = Depends(get_db)): + cats = await db.fetch( + 'SELECT nombre FROM "Categoria" WHERE activo = true ORDER BY orden' + ) + cat_names = [r["nombre"] for r in cats] + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([ + "nombre", "descripcion", "fase", "tipoPago", + "precioBase", "tiempoEntrega", "entregablesDefault", "variante", "categoria", + ]) + writer.writerow([ + "SEO On-Page", "Optimización on-page para buscadores", "Contenido y SEO", "mensual", + "2900", "7 - 14 dias", "Keyword research | On-page optimization", "", "SEO", + ]) + writer.writerow([ + "", "", "", "", "", "", "", "", "", + ]) + writer.writerow([f"CATEGORIAS: {', '.join(cat_names)}"]) + writer.writerow(["FASES: Auditoria, Setup e Infraestructura, Publicidad y Manejo, Contenido y SEO"]) + writer.writerow(["TIPOS DE PAGO: unico, mensual"]) + + return PlainTextResponse( + content=buf.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=plantilla-catalogo.csv"}, + ) diff --git a/api/app/routers/financiamiento.py b/api/app/routers/financiamiento.py new file mode 100644 index 0000000..c80c62f --- /dev/null +++ b/api/app/routers/financiamiento.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from asyncpg import Connection + +from app.dependencies import get_db +from app.models.export_ import ( + FinanciamientoRequest, + FinanciamientoResponse, + BonoResponse, + PlanBucefaloResponse, +) +from app.models.common import ErrorResponse +from app.auth import require_auth +from app.services.calculators import ( + calcular_financiamiento, + FINANCIAMIENTO_PLANES, + PLANES_BUCEFALO, +) + +router = APIRouter(prefix="/financiamiento", tags=["Financiamiento"]) + + +class PlanResponse(FinanciamientoResponse): + meses: int + tasa: float + comision: float + + +@router.get("/planes", response_model=list[PlanResponse]) +async def get_planes(_auth: dict = Depends(require_auth)): + return [ + PlanResponse( + meses=p["meses"], + tasa=p["tasa"], + comision=p["comision"], + **calcular_financiamiento(1000, p["meses"], p["tasa"], p["comision"]), + ) + for p in sorted(FINANCIAMIENTO_PLANES, key=lambda x: x["meses"]) + ] + + +@router.post( + "/calcular", + response_model=FinanciamientoResponse, + responses={400: {"model": ErrorResponse}}, +) +async def calcular( + body: FinanciamientoRequest, + _auth: dict = Depends(require_auth), +): + plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == body.meses), None) + if not plan: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"No financing plan available for {body.meses} months", + ) + if body.monto < plan["montoMinimo"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Minimum amount for {body.meses} months is ${plan['montoMinimo']}", + ) + + result = calcular_financiamiento(body.monto, body.meses, plan["tasa"], plan["comision"]) + return FinanciamientoResponse( + **result, + meses=body.meses, + tasa=plan["tasa"], + comision=plan["comision"], + ) + + +@router.get( + "/simulacion/{cotizacion_id}", + response_model=list[FinanciamientoResponse], + responses={404: {"model": ErrorResponse}}, +) +async def simulacion( + cotizacion_id: str, + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + cot = await db.fetchrow( + 'SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id + ) + if not cot: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cotización not found", + ) + + rows = await db.fetch( + 'SELECT precio FROM "ServicioCotizado" WHERE "cotizacionId" = $1 AND seleccionado = true', + cotizacion_id, + ) + total = sum(float(r["precio"]) for r in rows) + + plan_buc = await db.fetchrow( + 'SELECT precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', + cotizacion_id, + ) + if plan_buc: + total += float(plan_buc["precio"]) + + if total <= 0: + return [] + + results = [] + for plan in FINANCIAMIENTO_PLANES: + if total < plan["montoMinimo"]: + continue + result = calcular_financiamiento(total, plan["meses"], plan["tasa"], plan["comision"]) + results.append( + FinanciamientoResponse( + **result, + meses=plan["meses"], + tasa=plan["tasa"], + comision=plan["comision"], + ) + ) + + return results diff --git a/api/app/routers/health.py b/api/app/routers/health.py new file mode 100644 index 0000000..9360392 --- /dev/null +++ b/api/app/routers/health.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter + +from app.database import get_pool +from app.models.common import ErrorResponse, HealthResponse, APIInfoResponse + +router = APIRouter(tags=["Health"]) + + +@router.get("/health", response_model=HealthResponse) +async def health_check(): + pool = await get_pool() + try: + async with pool.acquire() as conn: + await conn.execute("SELECT 1") + db_status = "connected" + except Exception: + db_status = "disconnected" + + return HealthResponse( + status="ok", + timestamp=datetime.now(timezone.utc).isoformat(), + database=db_status, + ) + + +@router.get("/api-info", response_model=APIInfoResponse) +async def api_info(): + return APIInfoResponse( + name="Cotizador E3 API", + version="1.0.0", + description="API para el sistema de cotizaciones de Consultoría E3", + capabilities=[ + "Gestión de cotizaciones", + "Catálogo de servicios", + "Planes Bucéfalo CRM", + "Exportación PDF/Excel", + "Autenticación JWT", + "Financiamiento Openpay", + ], + ) diff --git a/api/app/routers/import_.py b/api/app/routers/import_.py new file mode 100644 index 0000000..1688399 --- /dev/null +++ b/api/app/routers/import_.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import csv +import io + +from fastapi import APIRouter, Depends, File, UploadFile +from asyncpg import Connection + +from app.dependencies import get_db +from app.auth import require_auth + +router = APIRouter(prefix="/import", tags=["Import"]) + +REQUIRED_COLUMNS = {"nombre", "fase", "tipoPago", "precioBase", "categoria"} + + +@router.post("/catalogo") +async def import_catalogo_csv( + file: UploadFile = File(...), + _auth: dict = Depends(require_auth), + db: Connection = Depends(get_db), +): + content = await file.read() + text = content.decode("utf-8-sig") + reader = csv.DictReader(io.StringIO(text)) + + if not reader.fieldnames or not REQUIRED_COLUMNS.issubset(set(reader.fieldnames)): + missing = REQUIRED_COLUMNS - set(reader.fieldnames or []) + return { + "creados": 0, + "omitidos": 0, + "errores": [f"Missing required columns: {', '.join(missing)}"], + } + + categorias = await db.fetch('SELECT id, nombre FROM "Categoria" WHERE activo = true') + cat_map = {r["nombre"]: r["id"] for r in categorias} + + existing = await db.fetch('SELECT nombre FROM "ServicioCatalogo" WHERE activo = true') + existing_names = {r["nombre"] for r in existing} + + creados = 0 + omitidos = 0 + errores: list[str] = [] + + async with db.transaction(): + for i, row in enumerate(reader, start=2): + nombre = (row.get("nombre") or "").strip() + if not nombre: + omitidos += 1 + continue + + if nombre in existing_names: + omitidos += 1 + continue + + cat_nombre = (row.get("categoria") or "").strip() + cat_id = cat_map.get(cat_nombre) + if not cat_id: + errores.append(f"Row {i}: category '{cat_nombre}' not found") + continue + + try: + fase = int(row.get("fase", "0")) + if fase not in (0, 1, 2, 3): + raise ValueError + except (ValueError, TypeError): + errores.append(f"Row {i}: invalid fase '{row.get('fase')}'") + continue + + tipo_pago = (row.get("tipoPago") or "").strip() + if tipo_pago not in ("unico", "mensual"): + errores.append(f"Row {i}: invalid tipoPago '{tipo_pago}'") + continue + + try: + precio = float(row.get("precioBase", "0")) + except (ValueError, TypeError): + errores.append(f"Row {i}: invalid precioBase '{row.get('precioBase')}'") + continue + + tiempo = (row.get("tiempoEntrega") or "7 - 14 dias").strip() + variante = (row.get("variante") or "").strip() or None + try: + orden = int(row.get("orden", "0")) + except (ValueError, TypeError): + orden = 0 + + entregables_raw = (row.get("entregablesDefault") or "").strip() + entregables = [e.strip() for e in entregables_raw.split(";") if e.strip()] if entregables_raw else [] + + await db.execute( + """ + INSERT INTO "ServicioCatalogo" + (id, nombre, descripcion, fase, "tipoPago", "precioBase", + "tiempoEntrega", "entregablesDefault", "categoriaId", + variante, orden, activo, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, now(), now()) + """, + nombre, + (row.get("descripcion") or "").strip() or None, + fase, + tipo_pago, + precio, + tiempo, + entregables if entregables else None, + cat_id, + variante, + orden, + ) + existing_names.add(nombre) + creados += 1 + + return {"creados": creados, "omitidos": omitidos, "errores": errores} diff --git a/api/app/routers/paquetes.py b/api/app/routers/paquetes.py new file mode 100644 index 0000000..22b0bf2 --- /dev/null +++ b/api/app/routers/paquetes.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.database import get_pool +from app.dependencies import get_db +from app.auth import require_auth +from app.models.paquete import ( + PaqueteCreate, + PaqueteUpdate, + PaqueteResponse, + FasePaqueteResponse, + ManageAction, +) +from app.models.common import ErrorResponse, OkResponse + +router = APIRouter(prefix="/paquetes", tags=["Paquetes"]) + + +async def _build_paquete_response(conn, paquete_row: dict) -> dict: + """Build a PaqueteResponse dict with nested fases and servicios.""" + paquete_id = paquete_row["id"] + + fases_rows = await conn.fetch( + 'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"' + ' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC', + paquete_id, + ) + + fases: list[dict] = [] + for fase in fases_rows: + servicios_rows = await conn.fetch( + 'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",' + ' sp."createdAt", sp."updatedAt",' + ' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,' + ' sc.fase AS sc_fase, sc."tipoPago" AS sc_tipoPago,' + ' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,' + ' sc."entregablesDefault" AS sc_entregablesDefault,' + ' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,' + ' sc.activo AS sc_activo, sc.orden AS sc_orden,' + ' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,' + ' cat.id AS cat_id, cat.nombre AS cat_nombre, cat.descripcion AS cat_descripcion,' + ' cat.color AS cat_color, cat.activo AS cat_activo, cat.orden AS cat_orden,' + ' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt' + ' FROM "ServicioPaquete" sp' + ' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"' + ' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"' + ' WHERE sp."fasePaqueteId" = $1', + fase["id"], + ) + + servicios: list[dict] = [] + for s in servicios_rows: + cat = None + if s["cat_id"]: + cat = { + "id": s["cat_id"], + "nombre": s["cat_nombre"], + "descripcion": s["cat_descripcion"], + "color": s["cat_color"], + "activo": s["cat_activo"], + "orden": s["cat_orden"], + "createdAt": s["cat_createdAt"], + "updatedAt": s["cat_updatedAt"], + } + servicios.append({ + "id": s["id"], + "servicioCatalogoId": s["servicioCatalogoId"], + "fasePaqueteId": s["fasePaqueteId"], + "createdAt": s["createdAt"], + "updatedAt": s["updatedAt"], + "servicio": { + "id": s["sc_id"], + "nombre": s["sc_nombre"], + "descripcion": s["sc_descripcion"], + "fase": s["sc_fase"], + "tipoPago": s["sc_tipoPago"], + "precioBase": s["sc_precioBase"], + "tiempoEntrega": s["sc_tiempoEntrega"], + "entregablesDefault": s["sc_entregablesDefault"], + "categoriaId": s["sc_categoriaId"], + "variante": s["sc_variante"], + "activo": s["sc_activo"], + "orden": s["sc_orden"], + "createdAt": s["sc_createdAt"], + "updatedAt": s["sc_updatedAt"], + "categoriaRel": cat, + }, + }) + + fases.append({ + "id": fase["id"], + "paqueteId": fase["paqueteId"], + "nombre": fase["nombre"], + "orden": fase["orden"], + "createdAt": fase["createdAt"], + "updatedAt": fase["updatedAt"], + "servicios": servicios, + }) + + return { + "id": paquete_row["id"], + "nombre": paquete_row["nombre"], + "descripcion": paquete_row["descripcion"], + "activo": paquete_row["activo"], + "createdAt": paquete_row["createdAt"], + "updatedAt": paquete_row["updatedAt"], + "fases": fases, + } + + +@router.get("", response_model=list[PaqueteResponse]) +async def list_paquetes( + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + rows = await conn.fetch( + 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' + ' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC' + ) + results: list[dict] = [] + for row in rows: + results.append(await _build_paquete_response(conn, dict(row))) + return results + + +@router.post( + "", + response_model=PaqueteResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_paquete( + body: PaqueteCreate, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + now = datetime.now(timezone.utc) + paquete_row = await conn.fetchrow( + 'INSERT INTO "Paquete" (id, nombre, descripcion, "createdAt", "updatedAt")' + " VALUES (gen_random_uuid(), $1, $2, $3, $3)" + ' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"', + body.nombre, + body.descripcion, + now, + ) + + paquete_id = paquete_row["id"] + for fase in body.fases: + await conn.execute( + 'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")' + " VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)", + paquete_id, + fase.nombre, + fase.orden, + now, + ) + + return await _build_paquete_response(conn, dict(paquete_row)) + + +@router.get( + "/{paquete_id}", + response_model=PaqueteResponse, + responses={404: {"model": ErrorResponse}}, +) +async def get_paquete( + paquete_id: str, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + row = await conn.fetchrow( + 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' + ' FROM "Paquete" WHERE id = $1', + paquete_id, + ) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Paquete no encontrado", + ) + return await _build_paquete_response(conn, dict(row)) + + +@router.put( + "/{paquete_id}", + response_model=PaqueteResponse, + responses={404: {"model": ErrorResponse}}, +) +async def update_paquete( + paquete_id: str, + body: PaqueteUpdate, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + existing = await conn.fetchrow( + 'SELECT id FROM "Paquete" WHERE id = $1', paquete_id + ) + if not existing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Paquete no encontrado", + ) + + updates: list[str] = [] + params: list = [] + idx = 1 + + if body.nombre is not None: + updates.append(f"nombre = ${idx}") + params.append(body.nombre) + idx += 1 + if body.descripcion is not None: + updates.append(f"descripcion = ${idx}") + params.append(body.descripcion) + idx += 1 + if body.activo is not None: + updates.append(f"activo = ${idx}") + params.append(body.activo) + idx += 1 + + if not updates: + row = await conn.fetchrow( + 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' + ' FROM "Paquete" WHERE id = $1', + paquete_id, + ) + return await _build_paquete_response(conn, dict(row)) + + updates.append(f'"updatedAt" = ${idx}') + params.append(datetime.now(timezone.utc)) + idx += 1 + + params.append(paquete_id) + row = await conn.fetchrow( + f'UPDATE "Paquete" SET {", ".join(updates)} WHERE id = ${idx}' + ' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"', + *params, + ) + return await _build_paquete_response(conn, dict(row)) + + +@router.delete( + "/{paquete_id}", + response_model=OkResponse, + responses={404: {"model": ErrorResponse}}, +) +async def delete_paquete( + paquete_id: str, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + result = await conn.execute( + 'DELETE FROM "Paquete" WHERE id = $1', paquete_id + ) + if result == "DELETE 0": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Paquete no encontrado", + ) + return OkResponse(ok=True) + + +@router.post( + "/{paquete_id}/manage", + responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}, +) +async def manage_paquete( + paquete_id: str, + body: ManageAction, + _auth: dict = Depends(require_auth), + conn=Depends(get_db), +): + paquete = await conn.fetchrow( + 'SELECT id FROM "Paquete" WHERE id = $1', paquete_id + ) + if not paquete: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Paquete no encontrado", + ) + + now = datetime.now(timezone.utc) + action = body.action + + if action == "addFase": + if not body.nombre: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El campo 'nombre' es requerido para addFase", + ) + row = await conn.fetchrow( + 'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")' + " VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)" + ' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"', + paquete_id, + body.nombre, + body.orden or 0, + now, + ) + return dict(row), status.HTTP_201_CREATED + + if action == "updateFase": + if not body.faseId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El campo 'faseId' es requerido para updateFase", + ) + updates: list[str] = [] + params: list = [] + idx = 1 + if body.nombre is not None: + updates.append(f"nombre = ${idx}") + params.append(body.nombre) + idx += 1 + if body.orden is not None: + updates.append(f"orden = ${idx}") + params.append(body.orden) + idx += 1 + if not updates: + row = await conn.fetchrow( + 'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"' + ' FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2', + body.faseId, + paquete_id, + ) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fase no encontrada", + ) + return dict(row) + updates.append(f'"updatedAt" = ${idx}') + params.append(now) + idx += 1 + params.extend([body.faseId, paquete_id]) + row = await conn.fetchrow( + f'UPDATE "FasePaquete" SET {", ".join(updates)}' + f" WHERE id = ${idx} AND \"paqueteId\" = ${idx + 1}" + ' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"', + *params, + ) + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fase no encontrada", + ) + return dict(row) + + if action == "deleteFase": + if not body.faseId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="El campo 'faseId' es requerido para deleteFase", + ) + result = await conn.execute( + 'DELETE FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2', + body.faseId, + paquete_id, + ) + if result == "DELETE 0": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fase no encontrada", + ) + return {"ok": True} + + if action == "addServicio": + if not body.servicioCatalogoId or not body.fasePaqueteId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para addServicio", + ) + fase = await conn.fetchrow( + 'SELECT id FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2', + body.fasePaqueteId, + paquete_id, + ) + if not fase: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Fase no encontrada en este paquete", + ) + try: + row = await conn.fetchrow( + 'INSERT INTO "ServicioPaquete" (id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt")' + " VALUES (gen_random_uuid(), $1, $2, $3, $3)" + ' RETURNING id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt"', + body.servicioCatalogoId, + body.fasePaqueteId, + now, + ) + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Este servicio ya está asignado a esta fase", + ) + raise + return dict(row), status.HTTP_201_CREATED + + if action == "removeServicio": + if not body.servicioCatalogoId or not body.fasePaqueteId: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para removeServicio", + ) + result = await conn.execute( + 'DELETE FROM "ServicioPaquete"' + ' WHERE "servicioCatalogoId" = $1 AND "fasePaqueteId" = $2', + body.servicioCatalogoId, + body.fasePaqueteId, + ) + if result == "DELETE 0": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Servicio no encontrado en esta fase", + ) + return {"ok": True} + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Acción desconocida: '{action}'. Acciones válidas: addFase, updateFase, deleteFase, addServicio, removeServicio", + ) diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/services/__pycache__/__init__.cpython-312.pyc b/api/app/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..80da2e3 Binary files /dev/null and b/api/app/services/__pycache__/__init__.cpython-312.pyc differ diff --git a/api/app/services/__pycache__/calculators.cpython-312.pyc b/api/app/services/__pycache__/calculators.cpython-312.pyc new file mode 100644 index 0000000..c57cd55 Binary files /dev/null and b/api/app/services/__pycache__/calculators.cpython-312.pyc differ diff --git a/api/app/services/__pycache__/excel_generator.cpython-312.pyc b/api/app/services/__pycache__/excel_generator.cpython-312.pyc new file mode 100644 index 0000000..7015583 Binary files /dev/null and b/api/app/services/__pycache__/excel_generator.cpython-312.pyc differ diff --git a/api/app/services/__pycache__/pdf_generator.cpython-312.pyc b/api/app/services/__pycache__/pdf_generator.cpython-312.pyc new file mode 100644 index 0000000..97598b7 Binary files /dev/null and b/api/app/services/__pycache__/pdf_generator.cpython-312.pyc differ diff --git a/api/app/services/calculators.py b/api/app/services/calculators.py new file mode 100644 index 0000000..39e682a --- /dev/null +++ b/api/app/services/calculators.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import date, datetime + + +IVA_RATE = 0.16 + +FASES: dict[int, str] = { + 0: "FASE 0 - Auditoria / Acompanamiento", + 1: "FASE 1 - Setup e Infraestructura", + 2: "FASE 2 - Publicidad y Manejo", + 3: "FASE 3 - Contenido y SEO", +} + +FASES_SHORT: dict[int, str] = { + 0: "FASE 0 - Auditoria", + 1: "FASE 1 - Setup e Infraestructura", + 2: "FASE 2 - Publicidad y Manejo", + 3: "FASE 3 - Contenido y SEO", +} + +PLANES_BUCEFALO = [ + {"nivel": "basico", "label": "Basico", "precio": 1000}, + {"nivel": "estandar", "label": "Estandar", "precio": 3500}, + {"nivel": "premium", "label": "Premium", "precio": 4500}, + {"nivel": "empresarial", "label": "Empresarial", "precio": 7500}, +] + +ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] + +FINANCIAMIENTO_PLANES = [ + {"meses": 3, "tasa": 0.077, "comision": 2.5, "montoMinimo": 300}, + {"meses": 6, "tasa": 0.107, "comision": 2.5, "montoMinimo": 600}, + {"meses": 9, "tasa": 0.137, "comision": 2.5, "montoMinimo": 900}, + {"meses": 12, "tasa": 0.167, "comision": 2.5, "montoMinimo": 1200}, +] + +BONOS = [ + {"id": "bono-1", "numero": 1, "titulo": "Servicio Centinela Web", "descripcion": "Monitoreo web 30 min/mes", "activo": True}, + {"id": "bono-2", "numero": 2, "titulo": "Workshop Buyer Persona", "descripcion": "Taller de definición de buyer persona", "activo": True}, + {"id": "bono-3", "numero": 3, "titulo": "Workshop Propuesta de Valor", "descripcion": "Taller de propuesta de valor", "activo": True}, + {"id": "bono-4", "numero": 4, "titulo": "Membresia Premium", "descripcion": "Membresía premium por 1 año", "activo": True}, + {"id": "bono-5", "numero": 5, "titulo": "Mes Gratis Bucefalo CRM", "descripcion": "Un mes gratis del CRM Bucefalo", "activo": True}, + {"id": "bono-6", "numero": 6, "titulo": "Script de Ventas", "descripcion": "Script de ventas con 100+ complementos", "activo": True}, +] + + +def bucefalo_precio(nivel: str) -> float: + for p in PLANES_BUCEFALO: + if p["nivel"] == nivel: + return float(p["precio"]) + return 0.0 + + +def calcular_vigencia(fecha: datetime | date) -> datetime: + if isinstance(fecha, datetime): + vigencia = fecha.replace() + else: + vigencia = datetime.combine(fecha, datetime.min.time()) + dias_habiles = 0 + while dias_habiles < 15: + vigencia = vigencia.replace(day=vigencia.day + 1) if vigencia.day < 28 else vigencia + __import__("datetime").timedelta(days=1) + vigencia = vigencia + __import__("datetime").timedelta(days=1) + dia = vigencia.weekday() + if dia < 5: + dias_habiles += 1 + return vigencia + + +def calcular_financiamiento(monto: float, meses: int, tasa: float, comision: float, iva: float = IVA_RATE) -> dict: + comision_total = (monto * comision) / 100 + monto_con_comision = monto + comision_total + pago_mensual = monto_con_comision * (1 + tasa) / meses + iva_mensual = pago_mensual * iva + total_mensual = pago_mensual + iva_mensual + return { + "pagoMensual": round(pago_mensual, 2), + "ivaMensual": round(iva_mensual, 2), + "totalMensual": round(total_mensual, 2), + "comisionTotal": round(comision_total, 2), + "granTotal": round(total_mensual * meses, 2), + } + + +def generar_numero_cotizacion(iniciales: str, secuencia: int) -> str: + now = datetime.now() + yy = str(now.year)[-2:] + mm = f"{now.month:02d}" + seq = f"{secuencia:03d}" + return f"UJ{yy}{mm}{iniciales}{seq}" + + +def sanitize_filename(name: str) -> str: + import re + name = re.sub(r"[^\w\s.\-]", "", name) + name = re.sub(r"\s+", " ", name) + return name.strip() + + +def format_currency(amount: float) -> str: + return f"${amount:,.2f}" + + +def format_date(d: datetime | date) -> str: + return f"{d.day:02d}/{d.month:02d}/{d.year}" diff --git a/api/app/services/excel_generator.py b/api/app/services/excel_generator.py new file mode 100644 index 0000000..db9a4ae --- /dev/null +++ b/api/app/services/excel_generator.py @@ -0,0 +1,339 @@ +"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl.""" + +from __future__ import annotations + +import io +import re +from datetime import datetime +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + +from app.services.calculators import FASES_SHORT, bucefalo_precio + +FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"} + + +def _hex_to_fill(hex_color: str) -> PatternFill: + h = hex_color.lstrip("#") + return PatternFill(start_color=f"FF{h.upper()}", end_color=f"FF{h.upper()}", fill_type="solid") + + +def _argb(hex_color: str, alpha: str = "FF") -> str: + h = hex_color.lstrip("#") + if len(h) > 6: + h = h[:6] + return f"{alpha}{h.upper()}" + + +def _thin_border(color: str = "FFD1D5DB") -> Border: + side = Side(style="thin", color=color) + return Border(top=side, left=side, bottom=side, right=side) + + +def _sanitize_sheet_name(name: str) -> str: + name = name[:31] + return re.sub(r"[/\\*?\[\]:]", "", name) + + +def _set_col_widths(ws, widths: list[tuple[str, float]]): + for col_letter, width in widths: + ws.column_dimensions[col_letter].width = width + + +def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes: + """Generate a multi-sheet Excel workbook for a quotation. + + Args: + data: dict with quotation data (same as PDF generator) + saved: if True, uses real quotation number instead of "BORRADOR" + """ + config_color = data.get("colorPrimario", "#2563eb") + PRIMARY = _argb(config_color) + PRIMARY_LIGHT = _argb(config_color, alpha="18") + SECONDARY = _argb(data.get("colorSecundario", "#1e293b")) + WHITE = "FFFFFFFF" + LIGHT_BG = "FFF8FAFC" + MUTED = "FF64748B" + BORDER_COLOR = "FFE2E8F0" + + header_font = Font(bold=True, size=10, name="Calibri", color=WHITE) + label_font = Font(bold=True, size=10, name="Calibri", color=MUTED) + value_font = Font(size=10, name="Calibri") + bold_font = Font(bold=True, size=10, name="Calibri") + total_font = Font(bold=True, size=11, name="Calibri") + small_font = Font(italic=True, size=9, name="Calibri", color=MUTED) + fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY) + + primary_fill = PatternFill(start_color=PRIMARY, end_color=PRIMARY, fill_type="solid") + primary_light_fill = PatternFill(start_color=PRIMARY_LIGHT, end_color=PRIMARY_LIGHT, fill_type="solid") + secondary_fill = PatternFill(start_color=SECONDARY, end_color=SECONDARY, fill_type="solid") + light_fill = PatternFill(start_color=LIGHT_BG, end_color=LIGHT_BG, fill_type="solid") + white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid") + + razon_social = data.get("razonSocial", "Cotizador E3") + cliente_nombre = data.get("clienteNombre", "") + cliente_empresa = data.get("clienteEmpresa", "") + asesor_nombre = data.get("asesorNombre", "") + fecha = data.get("fecha", datetime.now()) + vigencia = data.get("vigencia", datetime.now()) + moneda = data.get("moneda", "MXN") + tipo_cambio = data.get("tipoCambio", "NA") + proyecto = data.get("proyecto", "MKT Digital") + esquema = data.get("esquemaPago", "Pago Unico/Mensual") + servicios = data.get("servicios", []) + plan_nivel = data.get("planBucefaloNivel") + numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR" + empresa = cliente_empresa or cliente_nombre + + wb = Workbook() + wb.properties.creator = "Cotizador E3" + + # ── HOJA RESUMEN ────────────────────────────── + ws = wb.active + ws.title = "HOJA RESUMEN" + ws.sheet_view.showGridLines = False + + _set_col_widths(ws, [ + ("A", 3), ("B", 22), ("C", 22), ("D", 16), + ("E", 38), ("F", 5), ("G", 20), ("H", 5), + ("I", 20), ("J", 5), ("K", 18), + ]) + + # Header banner + ws.merge_cells("B2:K2") + cell = ws["B2"] + cell.value = razon_social + cell.font = Font(bold=True, size=16, name="Calibri", color=WHITE) + cell.alignment = Alignment(vertical="center") + for col in range(1, 12): + ws.cell(row=2, column=col).fill = primary_fill + + ws.merge_cells("B3:K3") + cell = ws["B3"] + cell.value = numero_label + cell.font = Font(bold=True, size=11, name="Calibri", color=WHITE) + cell.alignment = Alignment(vertical="center") + for col in range(1, 12): + ws.cell(row=3, column=col).fill = secondary_fill + + # Info section + info_start = 5 + info_rows = [ + [("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")], + [("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)], + [("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)], + [("B", "Fecha:"), ("C", fecha), ("E", "Esquema de Pago:"), ("F", esquema)], + ] + + for i, row_data in enumerate(info_rows): + r = info_start + i + for col_letter, label, is_value in [ + (row_data[0][0], row_data[0][1], False), + (row_data[1][0], row_data[1][1], True), + (row_data[2][0], row_data[2][1], False), + (row_data[3][0], row_data[3][1], True), + ]: + cell = ws[f"{col_letter}{r}"] + cell.value = label + cell.font = value_font if is_value else label_font + + # Vigencia + vigencia_row = info_start + 4 + ws[f"B{vigencia_row}"].value = "Vigencia:" + ws[f"B{vigencia_row}"].font = label_font + ws[f"C{vigencia_row}"].value = vigencia + ws[f"C{vigencia_row}"].number_format = "DD/MM/YYYY" + ws[f"C{vigencia_row}"].font = value_font + + # Services table + table_start = info_start + 6 + cols = ["B", "C", "D", "K"] + headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"] + + ws.merge_cells(f"D{table_start}:J{table_start}") + for i, col_letter in enumerate(cols): + cell = ws[f"{col_letter}{table_start}"] + cell.value = headers[i] + cell.font = header_font + cell.fill = primary_fill + cell.alignment = Alignment( + horizontal="right" if i == len(cols) - 1 else "left", + vertical="center", + ) + cell.border = _thin_border(PRIMARY) + ws.row_dimensions[table_start].height = 24 + + servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"] + servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"] + + row = table_start + 1 + current_fase = -1 + + for serv in servicios_unicos + servicios_mensuales: + fase = serv.get("fase", 0) + if fase != current_fase: + current_fase = fase + ws.merge_cells(f"B{row}:K{row}") + ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}") + ws[f"B{row}"].font = fase_font + for col in range(2, 12): + ws.cell(row=row, column=col).fill = primary_light_fill + row += 1 + + row_bg = light_fill if row % 2 == 0 else white_fill + + ws[f"B{row}"].value = "" + ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual" + ws[f"C{row}"].font = value_font + ws.merge_cells(f"D{row}:J{row}") + ws[f"D{row}"].value = serv.get("nombre", "") + ws[f"D{row}"].font = value_font + ws[f"K{row}"].value = serv.get("precio", 0) + ws[f"K{row}"].number_format = "$#,##0.00" + ws[f"K{row}"].font = value_font + ws[f"K{row}"].alignment = Alignment(horizontal="right") + + for col_letter in cols: + ws[f"{col_letter}{row}"].fill = row_bg + ws[f"{col_letter}{row}"].border = _thin_border(BORDER_COLOR) + row += 1 + + row += 1 + total_unico = sum(s.get("precio", 0) for s in servicios_unicos) + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = "Total Pago Unico" + ws[f"B{row}"].font = total_font + ws[f"K{row}"].value = total_unico + ws[f"K{row}"].number_format = "$#,##0.00" + ws[f"K{row}"].font = total_font + ws[f"K{row}"].alignment = Alignment(horizontal="right") + for col_letter in cols: + ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY) + ws[f"{col_letter}{row}"].fill = primary_light_fill + row += 1 + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = "(+ IVA)" + ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED) + row += 1 + + total_mensual = sum(s.get("precio", 0) for s in servicios_mensuales) + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = "Total Pago Mensual" + ws[f"B{row}"].font = total_font + ws[f"K{row}"].value = total_mensual + ws[f"K{row}"].number_format = "$#,##0.00" + ws[f"K{row}"].font = total_font + ws[f"K{row}"].alignment = Alignment(horizontal="right") + for col_letter in cols: + ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY) + ws[f"{col_letter}{row}"].fill = primary_light_fill + row += 1 + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = "(+ IVA)" + ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED) + row += 2 + + if plan_nivel: + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = f"CRM Bucefalo - {plan_nivel.capitalize()}" + ws[f"B{row}"].font = bold_font + ws[f"K{row}"].value = bucefalo_precio(plan_nivel) + ws[f"K{row}"].number_format = "$#,##0.00" + ws[f"K{row}"].font = bold_font + ws[f"K{row}"].alignment = Alignment(horizontal="right") + for col_letter in cols: + ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY) + ws[f"{col_letter}{row}"].fill = primary_light_fill + row += 2 + + ws.merge_cells(f"B{row}:K{row}") + ws[f"B{row}"].value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles." + ws[f"B{row}"].font = small_font + + # ── DETAIL SHEETS ────────────────────────────── + for serv in servicios: + safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio")) + dw = wb.create_sheet(title=safe_name) + dw.sheet_view.showGridLines = False + + _set_col_widths(dw, [ + ("A", 3), ("B", 35), ("C", 5), ("D", 15), ("E", 5), + ("F", 35), ("G", 5), ("H", 15), ("I", 5), ("J", 18), + ]) + + fase = serv.get("fase", 0) + dw.merge_cells("B1:J1") + dw["B1"].value = f"{FASES_MAP.get(fase, f'FASE {fase}')} \u2014 {serv.get('nombre', '')}" + dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE) + dw["B1"].alignment = Alignment(vertical="center") + for col in range(1, 11): + dw.cell(row=1, column=col).fill = primary_fill + dw.row_dimensions[1].height = 30 + + r = 3 + dw[f"B{r}"].value = "Cliente:" + dw[f"B{r}"].font = label_font + dw[f"C{r}"].value = cliente_empresa or cliente_nombre + dw[f"C{r}"].font = bold_font + dw[f"F{r}"].value = "No. Cotizacion:" + dw[f"F{r}"].font = label_font + dw[f"G{r}"].value = numero_label + dw[f"G{r}"].font = bold_font + r += 2 + + dw[f"B{r}"].value = "Servicio:" + dw[f"B{r}"].font = label_font + dw[f"C{r}"].value = serv.get("nombre", "") + dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY) + dw[f"F{r}"].value = "Tiempo de Entrega:" + dw[f"F{r}"].font = label_font + dw[f"G{r}"].value = serv.get("tiempoEntrega", "") + dw[f"G{r}"].font = value_font + r += 2 + + # Entregables header + for col_letter in ["B", "I"]: + dw[f"{col_letter}{r}"].fill = primary_fill + dw[f"{col_letter}{r}"].font = header_font + dw[f"B{r}"].value = "Entregable" + r += 1 + + entregables = serv.get("entregables", []) + for i, ent in enumerate(entregables): + bg = light_fill if i % 2 == 1 else white_fill + dw[f"B{i + r}"].value = f"{i + 1}. {ent}" + dw[f"B{i + r}"].font = value_font + dw[f"B{i + r}"].fill = bg + dw[f"B{i + r}"].border = _thin_border(BORDER_COLOR) + r += len(entregables) + 1 + + # Total + for col_letter in ["B", "I"]: + dw[f"{col_letter}{r}"].fill = primary_light_fill + dw[f"{col_letter}{r}"].border = _thin_border(PRIMARY) + tipo_label = "Unico" if serv.get("tipoPago") == "unico" else "Mensual" + dw[f"B{r}"].value = f"Total Pago {tipo_label}" + dw[f"B{r}"].font = total_font + dw[f"I{r}"].value = serv.get("precio", 0) + dw[f"I{r}"].number_format = "$#,##0.00" + dw[f"I{r}"].font = total_font + dw[f"I{r}"].alignment = Alignment(horizontal="right") + r += 1 + dw[f"B{r}"].value = "(+ IVA)" + dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED) + r += 2 + + dw.merge_cells(f"B{r}:J{r}") + dw[f"B{r}"].value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA." + dw[f"B{r}"].font = small_font + r += 2 + + dw[f"B{r}"].value = razon_social + dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY) + + output = io.BytesIO() + wb.save(output) + return output.getvalue() diff --git a/api/app/services/pdf_generator.py b/api/app/services/pdf_generator.py new file mode 100644 index 0000000..0fd7b5f --- /dev/null +++ b/api/app/services/pdf_generator.py @@ -0,0 +1,437 @@ +"""PDF Generator for Cotizador E3 — port of pdf-generator.ts using ReportLab.""" + +from __future__ import annotations + +import base64 +import io +from datetime import datetime +from typing import Any + +from reportlab.lib import colors +from reportlab.lib.pagesizes import LETTER +from reportlab.lib.units import inch +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader + +from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio + +PRIMARY_DEFAULT = "#2563eb" +DARK_DEFAULT = "#1e293b" +MUTED = "#64748b" +BORDER = "#cbd5e1" +LIGHT_BG = "#f1f5f9" +WHITE = "#ffffff" + +PAGE_W, PAGE_H = LETTER +MARGIN_TOP = 45 +MARGIN_BOTTOM = 55 +MARGIN_LEFT = 50 +MARGIN_RIGHT = 50 +CONTENT_W = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT + + +def _hex_to_rgb(hex_color: str) -> colors.Color: + h = hex_color.lstrip("#") + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return colors.Color(r / 255, g / 255, b / 255) + + +def _fmt_currency(n: float) -> str: + return f"${n:,.2f}" + + +def _fmt_date(d: datetime) -> str: + months = [ + "enero", "febrero", "marzo", "abril", "mayo", "junio", + "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre", + ] + return f"{d.day} de {months[d.month - 1]} de {d.year}" + + +def _load_logo(logo_base64: str | None, logo_mime: str | None) -> ImageReader | None: + if not logo_base64: + return None + try: + raw = base64.b64decode(logo_base64) + return io.BytesIO(raw) + except Exception: + return None + + +def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes: + """Generate a professional quotation PDF. + + Args: + data: dict with keys matching CotizacionPDFData interface: + numero, clienteNombre, clienteEmpresa, asesorNombre, fecha, vigencia, + moneda, tipoCambio, proyecto, esquemaPago, servicios[], + planBucefaloNivel, planBucefaloPrecio, incluirBonos, + configBancaria{}, colorPrimario, colorSecundario, logoBase64, logoMime + """ + PRIMARY = data.get("colorPrimario") or PRIMARY_DEFAULT + DARK = data.get("colorSecundario") or DARK_DEFAULT + primary_rgb = _hex_to_rgb(PRIMARY) + dark_rgb = _hex_to_rgb(DARK) + muted_rgb = _hex_to_rgb(MUTED) + border_rgb = _hex_to_rgb(BORDER) + light_rgb = _hex_to_rgb(LIGHT_BG) + white_rgb = _hex_to_rgb(WHITE) + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=LETTER) + c.setTitle(f"Cotizacion {data.get('numero', '')}") + + logo = _load_logo(data.get("logoBase64"), data.get("logoMime")) + + servicios = data.get("servicios", []) + plan_nivel = data.get("planBucefaloNivel") + plan_precio = data.get("planBucefaloPrecio", 0) + incluir_bonos = data.get("incluirBonos", False) + cfg_bancaria = data.get("configBancaria") or {} + + # ── PAGE 1: COVER ────────────────────────────── + c.setFillColor(white_rgb) + c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0) + + y = PAGE_H - 80 + if logo: + try: + c.drawImage(logo, MARGIN_LEFT, y - 20, width=65, height=65, preserveAspectRatio=True, mask="auto") + y -= 85 + except Exception: + logo = None + + if not logo: + c.setFont("Helvetica-Bold", 28) + c.setFillColor(primary_rgb) + c.drawString(MARGIN_LEFT, y, "UJ") + c.setFont("Helvetica", 9) + c.setFillColor(muted_rgb) + c.drawString(MARGIN_LEFT + 48, y - 15, "Uriel Jareth Consulting") + y -= 40 + + bar_y = y + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, bar_y, CONTENT_W, 4, fill=1, stroke=0) + + c.setFont("Helvetica-Bold", 36) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT, bar_y - 40, "Cotizacion") + c.setFont("Helvetica", 18) + c.setFillColor(primary_rgb) + c.drawString(MARGIN_LEFT, bar_y - 65, data.get("numero", "")) + + iy = bar_y - 100 + cL = MARGIN_LEFT + cR = MARGIN_LEFT + CONTENT_W * 0.52 + + info_l = [ + ("Cliente", data.get("clienteNombre", "")), + ("Empresa", data.get("clienteEmpresa", "")), + ("Proyecto", data.get("proyecto", "")), + ("Asesor", data.get("asesorNombre", "")), + ] + info_r = [ + ("Fecha", _fmt_date(data.get("fecha", datetime.now()))), + ("Vigencia", _fmt_date(data.get("vigencia", datetime.now()))), + ("Moneda", data.get("moneda", "MXN")), + ("Esquema", data.get("esquemaPago", "")), + ] + + for i in range(max(len(info_l), len(info_r))): + if i < len(info_l) and info_l[i][1]: + c.setFont("Helvetica", 9) + c.setFillColor(muted_rgb) + c.drawString(cL, iy, info_l[i][0]) + c.setFont("Helvetica", 11) + c.setFillColor(dark_rgb) + c.drawString(cL, iy - 14, info_l[i][1]) + if i < len(info_r) and info_r[i][1]: + c.setFont("Helvetica", 9) + c.setFillColor(muted_rgb) + c.drawString(cR, iy, info_r[i][0]) + c.setFont("Helvetica", 11) + c.setFillColor(dark_rgb) + c.drawString(cR, iy - 14, str(info_r[i][1])) + iy -= 32 + + # ── PAGE 2+: RESUMEN ────────────────────────── + c.showPage() + y = PAGE_H - MARGIN_TOP + + if logo: + try: + c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto") + except Exception: + pass + + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 15) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 12, y - 15, "Hoja Resumen") + y -= 28 + + cliente_ref = data.get("clienteEmpresa") or data.get("clienteNombre", "") + c.setFont("Helvetica", 8) + c.setFillColor(muted_rgb) + c.drawString(MARGIN_LEFT, y, f"En atencion a: {cliente_ref}") + c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"No. Cotizacion: {data.get('numero', '')}") + y -= 11 + c.drawString(MARGIN_LEFT, y, f"Asesor: {data.get('asesorNombre', '')}") + c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"Fecha: {_fmt_date(data.get('fecha', datetime.now()))} | Moneda: {data.get('moneda', 'MXN')}") + y -= 18 + + col_nombre = MARGIN_LEFT + col_tipo = MARGIN_LEFT + CONTENT_W - 200 + col_tiempo = MARGIN_LEFT + CONTENT_W - 120 + col_precio = MARGIN_LEFT + CONTENT_W - 60 + + # Header row + c.setFillColor(light_rgb) + c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 16, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 7) + c.setFillColor(dark_rgb) + c.drawString(col_nombre + 6, y + 3, "Servicio") + c.drawString(col_tipo + 4, y + 3, "Tipo") + c.drawString(col_tiempo + 4, y + 3, "Entrega") + c.drawRightString(col_precio + 60, y + 3, "Precio") + y -= 4 + + c.setStrokeColor(border_rgb) + c.setLineWidth(0.3) + c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y) + y -= 14 + + unicos = [s for s in servicios if s.get("tipoPago") == "unico"] + mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"] + + def _draw_section(srv_list, tipo_label, titulo_total, y_pos): + if not srv_list: + return y_pos + current_fase = -1 + max_y = PAGE_H - MARGIN_BOTTOM - 5 + + for serv in srv_list: + fase = serv.get("fase", 0) + if fase != current_fase: + current_fase = fase + if y_pos - 20 < max_y - PAGE_H: + c.showPage() + y_pos = PAGE_H - MARGIN_TOP + c.setFillColor(light_rgb) + c.rect(MARGIN_LEFT, y_pos - 14, CONTENT_W, 14, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 7) + c.setFillColor(primary_rgb) + c.drawString(MARGIN_LEFT + 6, y_pos - 11, FASES_SHORT.get(fase, f"FASE {fase}")) + y_pos -= 18 + + c.setFont("Helvetica", 8) + c.setFillColor(dark_rgb) + nombre = serv.get("nombre", "") + c.drawString(col_nombre + 6, y_pos - 10, nombre[:60]) + c.setFont("Helvetica", 7) + c.setFillColor(muted_rgb) + c.drawString(col_tipo + 4, y_pos - 10, tipo_label) + c.drawString(col_tiempo + 4, y_pos - 10, serv.get("tiempoEntrega", "")[:15]) + c.setFont("Helvetica-Bold", 8) + c.setFillColor(dark_rgb) + c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0))) + y_pos -= 14 + + entregables = serv.get("entregables", []) + if entregables: + half = (len(entregables) + 1) // 2 + for idx in range(half): + e1 = entregables[idx] + e2 = entregables[idx + half] if idx + half < len(entregables) else None + c.setFont("Helvetica", 6) + c.setFillColor(muted_rgb) + c.drawString(col_nombre + 10, y_pos - 8, f"\u2022 {e1[:50]}") + if e2: + c.drawString(col_nombre + CONTENT_W * 0.48, y_pos - 8, f"\u2022 {e2[:50]}") + y_pos -= 9 + + c.setStrokeColor(_hex_to_rgb("#e5e7eb")) + c.setLineWidth(0.2) + c.line(col_nombre + 6, y_pos, MARGIN_LEFT + CONTENT_W, y_pos) + y_pos -= 6 + + total = sum(s.get("precio", 0) for s in srv_list) + y_pos -= 4 + c.setFillColor(_hex_to_rgb("#f8fafc")) + c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 18, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 8.5) + c.setFillColor(primary_rgb) + c.drawString(col_nombre + 6, y_pos + 3, titulo_total) + c.drawRightString(col_precio + 60, y_pos + 3, _fmt_currency(total)) + y_pos -= 18 + c.setFont("Helvetica", 6) + c.setFillColor(muted_rgb) + c.drawString(col_nombre + 6, y_pos, "(Precios en Moneda Nacional, no incluyen IVA)") + y_pos -= 14 + return y_pos + + if unicos: + y = _draw_section(unicos, "Unico", "Total Pago Unico", y) + if mensuales: + y = _draw_section(mensuales, "Mensual", "Total Pago Mensual", y) + + if plan_nivel: + label = plan_nivel.capitalize() + c.setFillColor(light_rgb) + c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 14, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 7.5) + c.setFillColor(dark_rgb) + c.drawString(col_nombre + 6, y + 3, f"Plan Bucefalo CRM - {label}") + c.drawRightString(col_precio + 60, y + 3, _fmt_currency(plan_precio)) + y -= 20 + + if incluir_bonos: + y -= 6 + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, y - 10, 3, 10, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 9) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 10, y - 8, "Bonos (Pago en una exhibicion)") + y -= 20 + bonos = [ + "Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)", + "Bono 2: Workshop Estrategico de Buyer Persona", + "Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible", + "Bono 4: 1 ano de Membresia Premium", + "Bono 5: Un mes gratis de Bucefalo CRM", + "Bono 6: Script de Ventas con mas de 100 complementos", + ] + for b in bonos: + c.setFont("Helvetica", 7) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 8, y - 8, f"\u2713 {b}") + y -= 12 + + # ── T&C PAGE ────────────────────────────────── + c.showPage() + y = PAGE_H - MARGIN_TOP + + if logo: + try: + c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto") + except Exception: + pass + + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 15) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 12, y - 15, "Terminos y Condiciones") + y -= 30 + + def _section_title(title, yy): + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, yy - 9, 3, 9, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 9) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 10, yy - 8, title) + return yy - 20 + + def _draw_bullets(title, items, yy): + yy = _section_title(title, yy) + for t in items: + c.setFont("Helvetica", 7.5) + c.setFillColor(dark_rgb) + # Simple text wrapping + words = t.split() + line = "" + for word in words: + test = f"{line} {word}".strip() + if c.stringWidth(test, "Helvetica", 7.5) > CONTENT_W - 10: + c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}") + yy -= 11 + line = word + else: + line = test + if line: + c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}") + yy -= 11 + return yy - 8 + + y = _draw_bullets("Condiciones", [ + "Esta cotizacion tiene una vigencia de 15 dias habiles.", + "Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.", + "El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.", + "Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.", + "Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.", + "Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.", + ], y) + + y = _draw_bullets("Que no incluye el proyecto?", [ + "Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado.", + "Redaccion de entradas de Blog.", + "Integracion de Servicios de terceros ajenos a los cotizados.", + "Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads.", + "Creacion de Redes Sociales (En caso de requerir el servicio incluira un costo adicional).", + ], y) + + y = _draw_bullets("Notas", [ + "El presente proyecto debera tener un responsable oficial.", + "La Hora Centinela tiene un precio de $700.00 MXN.", + "Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia. Si requiere los archivos editables, estos pueden ser adquiridos abonando una tarifa de liberacion (buy-out fee).", + "Si el proyecto se pausa por razones ajenas a Uriel Jareth Consulting, esto generara costo extra del 15% al 30% para retomar el proyecto.", + ], y) + + y -= 8 + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, y - 9, 3, 9, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 9) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 10, y - 8, "Datos Bancarios") + y -= 20 + + razon_social = cfg_bancaria.get("razon_social", "URIEL JARETH ALVARADO ORTIZ") + rfc = cfg_bancaria.get("rfc", "AAOU970201SU7") + clabe_nac = cfg_bancaria.get("clabe_interbancaria", cfg_bancaria.get("cuenta_nacional", "")) + cuenta_nac = cfg_bancaria.get("cuenta_nacional", "") + banco_nac = "BBVA" if cuenta_nac else "" + + box_h = 48 + c.setFillColor(light_rgb) + c.setStrokeColor(border_rgb) + c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1) + c.setFont("Helvetica-Bold", 7) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Nacional") + c.setFont("Helvetica", 6.5) + if cuenta_nac: + c.drawString(MARGIN_LEFT + 8, y - 24, f"Cuenta: {cuenta_nac}") + if clabe_nac: + c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 24, f"CLABE: {clabe_nac}") + c.drawString(MARGIN_LEFT + 8, y - 35, f"Razon Social: {razon_social}") + c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, f"RFC: {rfc}") + if banco_nac: + c.drawString(MARGIN_LEFT + 8, y - 46, f"Banco: {banco_nac}") + y -= box_h + 8 + + c.setFillColor(light_rgb) + c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1) + c.setFont("Helvetica-Bold", 7) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Internacional") + c.setFont("Helvetica", 6.5) + c.drawString(MARGIN_LEFT + 8, y - 24, f"Beneficiario: {razon_social}") + c.drawString(MARGIN_LEFT + 8, y - 35, "Banco: BBVA Mexico") + c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, "SWIFT: BCMRMXMMPYM") + + # ── FOOTER ON ALL PAGES ────────────────────── + page_count = c.getPageNumber() + for i in range(1, page_count + 1): + c.setPageSize(LETTER) + c.setFont("Helvetica", 6) + c.setFillColor(muted_rgb) + c.setStrokeColor(border_rgb) + c.setLineWidth(0.5) + c.line(MARGIN_LEFT, MARGIN_BOTTOM - 10, MARGIN_LEFT + CONTENT_W, MARGIN_BOTTOM - 10) + c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 22, "Uriel Jareth Consulting") + c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 32, "urieljareth.com | contacto@urieljareth.com | (445) 182 9943") + + c.save() + return buf.getvalue() diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..24086eb --- /dev/null +++ b/api/main.py @@ -0,0 +1,142 @@ +"""Cotizador E3 — FastAPI Application + +REST API + MCP Server for digital marketing quotation management. +Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT). +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.config import settings +from app.database import close_db, init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + await close_db() + + +app = FastAPI( + title="Cotizador E3 API", + description=( + "API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. " + "Optimizada para integración con n8n y agentes de IA (OpenClaw, Claude, ChatGPT) " + "via MCP (Model Context Protocol)." + ), + version="1.0.0", + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + openapi_tags=[ + {"name": "Health", "description": "Health check y información de la API"}, + {"name": "Auth", "description": "Autenticación (API Key + JWT)"}, + {"name": "Clientes", "description": "Gestión de clientes"}, + {"name": "Catálogo", "description": "Catálogo de servicios de marketing digital"}, + {"name": "Categorías", "description": "Categorías de servicios"}, + {"name": "Cotizaciones", "description": "Cotizaciones — CRUD completo con servicios y plan CRM"}, + {"name": "Paquetes", "description": "Paquetes de servicios con fases"}, + {"name": "Configuración", "description": "Configuración de la empresa (key-value)"}, + {"name": "Bonos", "description": "Bonos disponibles para cotizaciones"}, + {"name": "Financiamiento", "description": "Planes y cálculo de financiamiento"}, + {"name": "Export", "description": "Exportación de PDF, Excel y CSV"}, + {"name": "Import", "description": "Importación masiva de servicios"}, + ], +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + return JSONResponse( + status_code=500, + content={"error": "Error interno del servidor", "detail": str(exc)}, + ) + + +# Register routers +from app.routers import ( + auth, + bonos, + catalogo, + categorias, + clientes, + configuracion, + cotizaciones, + export_, + financiamiento, + health, + import_, + paquetes, +) + +app.include_router(health.router) +app.include_router(auth.router) +app.include_router(clientes.router) +app.include_router(catalogo.router) +app.include_router(categorias.router) +app.include_router(cotizaciones.router) +app.include_router(paquetes.router) +app.include_router(configuracion.router) +app.include_router(bonos.router) +app.include_router(financiamiento.router) +app.include_router(export_.router) +app.include_router(import_.router) + +# Mount MCP server +try: + from app.mcp.server import server as mcp_server + + from mcp.server.streamable_http import StreamableHTTPServerTransport + + @app.post("/mcp") + async def mcp_endpoint(request: Request): + """MCP (Model Context Protocol) endpoint for AI agents.""" + transport = StreamableHTTPServerTransport(mcp_server) + return await transport.handle_request(request) + + @app.get("/mcp") + async def mcp_info(): + """MCP server info. Use POST for actual MCP communication.""" + return { + "name": "cotizador-e3", + "version": "1.0.0", + "protocol": "mcp", + "description": "MCP server for Cotizador E3 quotation system", + } +except ImportError: + # MCP SDK not installed, skip MCP mount + pass + + +@app.get("/", include_in_schema=False) +async def root(): + return { + "name": "Cotizador E3 API", + "version": "1.0.0", + "docs": "/docs", + "openapi": "/openapi.json", + "health": "/health", + "mcp": "/mcp", + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host=settings.API_HOST, port=settings.API_PORT, reload=True) diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..84e7b13 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +asyncpg>=0.30.0 +pydantic>=2.0 +pydantic-settings>=2.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.18 +reportlab>=4.0 +openpyxl>=3.1.0 +mcp>=1.0.0 +httpx>=0.27.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f223d2d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: postgres:16-alpine + container_name: cotizador-e3-db + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: cotizador_e3 + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + api: + build: ./api + container_name: cotizador-e3-api + restart: unless-stopped + ports: + - "8000:8000" + environment: + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=postgres + - DB_PASSWORD=postgres + - DB_NAME=cotizador_e3 + - API_KEY=${API_KEY:-change-me} + - JWT_SECRET=${JWT_SECRET:-change-me} + depends_on: + - postgres + +volumes: + postgres_data: diff --git a/next.config.ts b/next.config.ts index e9ffa30..7793f7d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + serverExternalPackages: ["pdfkit"], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index b107bcf..1459fc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,33 @@ "name": "cotizador-e3", "version": "0.1.0", "dependencies": { + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", + "bcryptjs": "^3.0.3", + "clsx": "^2.1.1", + "dotenv": "^17.4.2", + "exceljs": "^4.4.0", + "jose": "^6.2.3", + "lucide-react": "^1.11.0", "next": "16.2.4", + "pdfkit": "^0.18.0", + "pg": "^8.20.0", + "prisma": "^7.8.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/pdfkit": "^0.17.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5" } }, @@ -276,6 +291,33 @@ "node": ">=6.9.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -309,6 +351,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -453,6 +937,59 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1083,6 +1620,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1252,6 +1795,30 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1300,6 +1867,365 @@ "node": ">=12.4.0" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1307,6 +2233,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1610,6 +2542,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1635,17 +2574,36 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1655,7 +2613,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2305,6 +3263,81 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2489,6 +3522,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2515,6 +3554,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", @@ -2539,7 +3587,26 @@ "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { @@ -2554,11 +3621,64 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-result": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.8.2.tgz", + "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2578,6 +3698,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2612,6 +3750,84 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2692,6 +3908,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2709,12 +3937,57 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "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", @@ -2735,11 +4008,31 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "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/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, "node_modules/convert-source-map": { @@ -2749,11 +4042,41 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 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", @@ -2768,7 +4091,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2832,6 +4154,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2857,6 +4185,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2893,6 +4230,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2903,6 +4261,12 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2916,6 +4280,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2931,6 +4307,61 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -2945,6 +4376,24 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -2959,6 +4408,18 @@ "node": ">=10.13.0" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3136,6 +4597,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3565,11 +5068,71 @@ "node": ">=0.10.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3616,6 +5179,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3690,6 +5269,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3706,6 +5302,65 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3747,6 +5402,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -3792,6 +5456,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3837,6 +5507,36 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.2.0.tgz", + "integrity": "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==", + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3897,9 +5597,20 @@ "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/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4011,6 +5722,57 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hono": { + "version": "4.12.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4021,6 +5783,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4048,6 +5816,23 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4333,6 +6118,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4489,7 +6280,6 @@ "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/iterator.prototype": { @@ -4514,12 +6304,26 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4603,6 +6407,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4633,6 +6485,54 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4647,6 +6547,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4920,6 +6829,31 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4936,6 +6870,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "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==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4943,6 +6944,24 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4966,6 +6985,30 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/lucide-react": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", + "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5014,7 +7057,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5027,12 +7069,23 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5040,6 +7093,38 @@ "dev": true, "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5188,6 +7273,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5311,6 +7405,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5379,6 +7488,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5402,11 +7517,19 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "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" @@ -5419,6 +7542,130 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/pdfkit": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz", + "integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5438,6 +7685,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5477,6 +7743,58 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5487,6 +7805,45 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5499,6 +7856,23 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5509,6 +7883,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5530,6 +7920,16 @@ ], "license": "MIT" }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -5558,6 +7958,63 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5602,6 +8059,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -5646,6 +8121,21 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5657,6 +8147,19 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5701,6 +8204,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "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/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5736,6 +8259,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5752,6 +8293,11 @@ "semver": "bin/semver.js" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5801,6 +8347,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -5863,7 +8415,6 @@ "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" @@ -5876,7 +8427,6 @@ "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" @@ -5958,6 +8508,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5967,6 +8529,24 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -5974,6 +8554,12 @@ "dev": true, "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5988,6 +8574,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6194,6 +8789,28 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6242,6 +8859,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6255,6 +8881,15 @@ "node": ">=8.0" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6300,6 +8935,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6395,7 +9050,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6452,7 +9107,32 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6490,6 +9170,60 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6531,11 +9265,39 @@ "punycode": "^2.1.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==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "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" @@ -6646,6 +9408,27 @@ "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==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6666,6 +9449,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", @@ -6688,6 +9516,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 245cdce..ef218c0 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,43 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "db:migrate": "npx prisma migrate dev", + "db:seed": "npx tsx prisma/seed.ts", + "db:studio": "npx prisma studio", + "db:generate": "npx prisma generate" + }, + "prisma": { + "seed": "npx tsx prisma/seed.ts" }, "dependencies": { + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", + "bcryptjs": "^3.0.3", + "clsx": "^2.1.1", + "dotenv": "^17.4.2", + "exceljs": "^4.4.0", + "jose": "^6.2.3", + "lucide-react": "^1.11.0", "next": "16.2.4", + "pdfkit": "^0.18.0", + "pg": "^8.20.0", + "prisma": "^7.8.0", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "zustand": "^5.0.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/pdfkit": "^0.17.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..6cff133 --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,13 @@ +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "npx tsx prisma/seed.ts", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260427090524_init/migration.sql b/prisma/migrations/20260427090524_init/migration.sql new file mode 100644 index 0000000..6b4ace4 --- /dev/null +++ b/prisma/migrations/20260427090524_init/migration.sql @@ -0,0 +1,161 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'asesor', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Cliente" ( + "id" TEXT NOT NULL, + "nombre" TEXT NOT NULL, + "empresa" TEXT, + "email" TEXT, + "telefono" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cliente_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Cotizacion" ( + "id" TEXT NOT NULL, + "numero" TEXT NOT NULL, + "fecha" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "vigencia" TIMESTAMP(3) NOT NULL, + "moneda" TEXT NOT NULL DEFAULT 'MXN', + "tipoCambio" TEXT NOT NULL DEFAULT 'NA', + "proyecto" TEXT NOT NULL DEFAULT 'MKT Digital', + "esquemaPago" TEXT NOT NULL DEFAULT 'Pago Unico/Mensual', + "estado" TEXT NOT NULL DEFAULT 'borrador', + "incluirBonos" BOOLEAN NOT NULL DEFAULT false, + "incluirFinanciamiento" BOOLEAN NOT NULL DEFAULT false, + "observaciones" TEXT, + "clienteId" TEXT NOT NULL, + "asesorId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cotizacion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServicioCatalogo" ( + "id" TEXT NOT NULL, + "nombre" TEXT NOT NULL, + "descripcion" TEXT, + "fase" INTEGER NOT NULL DEFAULT 1, + "tipoPago" TEXT NOT NULL DEFAULT 'unico', + "precioBase" DOUBLE PRECISION NOT NULL, + "tiempoEntrega" TEXT NOT NULL DEFAULT '4 - 10 dias', + "entregablesDefault" JSONB NOT NULL DEFAULT '[]', + "categoria" TEXT NOT NULL DEFAULT 'otros', + "variante" TEXT, + "activo" BOOLEAN NOT NULL DEFAULT true, + "orden" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServicioCatalogo_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServicioCotizado" ( + "id" TEXT NOT NULL, + "cotizacionId" TEXT NOT NULL, + "servicioCatalogoId" TEXT NOT NULL, + "fase" INTEGER NOT NULL, + "tipoPago" TEXT NOT NULL, + "precio" DOUBLE PRECISION NOT NULL, + "tiempoEntrega" TEXT NOT NULL, + "entregables" JSONB NOT NULL DEFAULT '[]', + "notas" TEXT, + "seleccionado" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServicioCotizado_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PlanBucefaloCotizacion" ( + "id" TEXT NOT NULL, + "cotizacionId" TEXT NOT NULL, + "nivel" TEXT NOT NULL DEFAULT 'basico', + "precio" DOUBLE PRECISION NOT NULL, + "seleccionado" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlanBucefaloCotizacion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Configuracion" ( + "id" TEXT NOT NULL, + "clave" TEXT NOT NULL, + "valor" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Configuracion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Bono" ( + "id" TEXT NOT NULL, + "numero" INTEGER NOT NULL, + "titulo" TEXT NOT NULL, + "descripcion" TEXT NOT NULL, + "activo" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Bono_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FinanciamientoPlan" ( + "id" TEXT NOT NULL, + "meses" INTEGER NOT NULL, + "tasa" DOUBLE PRECISION NOT NULL, + "comision" DOUBLE PRECISION NOT NULL, + "montoMinimo" DOUBLE PRECISION NOT NULL, + "iva" DOUBLE PRECISION NOT NULL DEFAULT 0.16, + + CONSTRAINT "FinanciamientoPlan_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cotizacion_numero_key" ON "Cotizacion"("numero"); + +-- CreateIndex +CREATE UNIQUE INDEX "PlanBucefaloCotizacion_cotizacionId_key" ON "PlanBucefaloCotizacion"("cotizacionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Configuracion_clave_key" ON "Configuracion"("clave"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinanciamientoPlan_meses_key" ON "FinanciamientoPlan"("meses"); + +-- AddForeignKey +ALTER TABLE "Cotizacion" ADD CONSTRAINT "Cotizacion_clienteId_fkey" FOREIGN KEY ("clienteId") REFERENCES "Cliente"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Cotizacion" ADD CONSTRAINT "Cotizacion_asesorId_fkey" FOREIGN KEY ("asesorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServicioCotizado" ADD CONSTRAINT "ServicioCotizado_cotizacionId_fkey" FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServicioCotizado" ADD CONSTRAINT "ServicioCotizado_servicioCatalogoId_fkey" FOREIGN KEY ("servicioCatalogoId") REFERENCES "ServicioCatalogo"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlanBucefaloCotizacion" ADD CONSTRAINT "PlanBucefaloCotizacion_cotizacionId_fkey" FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260430154327_add_packages_categories/migration.sql b/prisma/migrations/20260430154327_add_packages_categories/migration.sql new file mode 100644 index 0000000..4276b32 --- /dev/null +++ b/prisma/migrations/20260430154327_add_packages_categories/migration.sql @@ -0,0 +1,69 @@ +-- AlterTable +ALTER TABLE "ServicioCatalogo" ADD COLUMN "categoriaId" TEXT; + +-- CreateTable +CREATE TABLE "Categoria" ( + "id" TEXT NOT NULL, + "nombre" TEXT NOT NULL, + "descripcion" TEXT, + "color" TEXT NOT NULL DEFAULT '#6b7280', + "activo" BOOLEAN NOT NULL DEFAULT true, + "orden" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Categoria_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Paquete" ( + "id" TEXT NOT NULL, + "nombre" TEXT NOT NULL, + "descripcion" TEXT, + "activo" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Paquete_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FasePaquete" ( + "id" TEXT NOT NULL, + "paqueteId" TEXT NOT NULL, + "nombre" TEXT NOT NULL, + "orden" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FasePaquete_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServicioPaquete" ( + "id" TEXT NOT NULL, + "servicioCatalogoId" TEXT NOT NULL, + "fasePaqueteId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServicioPaquete_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Categoria_nombre_key" ON "Categoria"("nombre"); + +-- CreateIndex +CREATE UNIQUE INDEX "ServicioPaquete_servicioCatalogoId_fasePaqueteId_key" ON "ServicioPaquete"("servicioCatalogoId", "fasePaqueteId"); + +-- AddForeignKey +ALTER TABLE "FasePaquete" ADD CONSTRAINT "FasePaquete_paqueteId_fkey" FOREIGN KEY ("paqueteId") REFERENCES "Paquete"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServicioCatalogo" ADD CONSTRAINT "ServicioCatalogo_categoriaId_fkey" FOREIGN KEY ("categoriaId") REFERENCES "Categoria"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServicioPaquete" ADD CONSTRAINT "ServicioPaquete_servicioCatalogoId_fkey" FOREIGN KEY ("servicioCatalogoId") REFERENCES "ServicioCatalogo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServicioPaquete" ADD CONSTRAINT "ServicioPaquete_fasePaqueteId_fkey" FOREIGN KEY ("fasePaqueteId") REFERENCES "FasePaquete"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260430180000_remove_categoria_string/migration.sql b/prisma/migrations/20260430180000_remove_categoria_string/migration.sql new file mode 100644 index 0000000..5a96924 --- /dev/null +++ b/prisma/migrations/20260430180000_remove_categoria_string/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ServicioCatalogo" DROP COLUMN "categoria"; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a1248c2 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,180 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model User { + id String @id @default(cuid()) + email String @unique + password String + name String + role String @default("asesor") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cotizaciones Cotizacion[] @relation("AsesorCotizaciones") +} + +model Cliente { + id String @id @default(cuid()) + nombre String + empresa String? + email String? + telefono String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cotizaciones Cotizacion[] +} + +model Cotizacion { + id String @id @default(cuid()) + numero String @unique + fecha DateTime @default(now()) + vigencia DateTime + moneda String @default("MXN") + tipoCambio String @default("NA") + proyecto String @default("MKT Digital") + esquemaPago String @default("Pago Unico/Mensual") + estado String @default("borrador") + incluirBonos Boolean @default(false) + incluirFinanciamiento Boolean @default(false) + observaciones String? + clienteId String + asesorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cliente Cliente @relation(fields: [clienteId], references: [id]) + asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id]) + servicios ServicioCotizado[] + planBucefalo PlanBucefaloCotizacion? +} + +model Categoria { + id String @id @default(cuid()) + nombre String @unique + descripcion String? + color String @default("#6b7280") + activo Boolean @default(true) + orden Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + servicios ServicioCatalogo[] +} + +model Paquete { + id String @id @default(cuid()) + nombre String + descripcion String? + activo Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + fases FasePaquete[] +} + +model FasePaquete { + id String @id @default(cuid()) + paqueteId String + nombre String + orden Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + paquete Paquete @relation(fields: [paqueteId], references: [id], onDelete: Cascade) + servicios ServicioPaquete[] +} + +model ServicioCatalogo { + id String @id @default(cuid()) + nombre String + descripcion String? + fase Int @default(1) + tipoPago String @default("unico") + precioBase Float + tiempoEntrega String @default("4 - 10 dias") + entregablesDefault Json @default("[]") + categoriaId String? + variante String? + activo Boolean @default(true) + orden Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + servicios ServicioCotizado[] + categoriaRel Categoria? @relation(fields: [categoriaId], references: [id]) + paquetes ServicioPaquete[] +} + +model ServicioPaquete { + id String @id @default(cuid()) + servicioCatalogoId String + fasePaqueteId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + servicio ServicioCatalogo @relation(fields: [servicioCatalogoId], references: [id], onDelete: Cascade) + fasePaquete FasePaquete @relation(fields: [fasePaqueteId], references: [id], onDelete: Cascade) + + @@unique([servicioCatalogoId, fasePaqueteId]) +} + +model ServicioCotizado { + id String @id @default(cuid()) + cotizacionId String + servicioCatalogoId String + fase Int + tipoPago String + precio Float + tiempoEntrega String + entregables Json @default("[]") + notas String? + seleccionado Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) + servicioCatalogo ServicioCatalogo @relation(fields: [servicioCatalogoId], references: [id]) +} + +model PlanBucefaloCotizacion { + id String @id @default(cuid()) + cotizacionId String @unique + nivel String @default("basico") + precio Float + seleccionado Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) +} + +model Configuracion { + id String @id @default(cuid()) + clave String @unique + valor String + updatedAt DateTime @updatedAt +} + +model Bono { + id String @id @default(cuid()) + numero Int + titulo String + descripcion String + activo Boolean @default(true) +} + +model FinanciamientoPlan { + id String @id @default(cuid()) + meses Int @unique + tasa Float + comision Float + montoMinimo Float + iva Float @default(0.16) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..e84fb1d --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,760 @@ +import { PrismaClient } from "../src/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { hash } from "bcryptjs"; + +const adapter = new PrismaPg({ + host: "localhost", + port: 5432, + user: "postgres", + password: "postgres", + database: "cotizador_e3", +}); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("Seeding database..."); + + const adminHash = await hash("cee564F1.", 10); + const asesorHash = await hash("asesor123", 10); + + await prisma.user.upsert({ + where: { email: "urieljareth@gmail.com" }, + update: { password: adminHash }, + create: { + email: "urieljareth@gmail.com", + password: adminHash, + name: "Uriel Jareth Alvarado Ortiz", + role: "admin", + }, + }); + + await prisma.user.upsert({ + where: { email: "asesor@urieljareth.com" }, + update: { password: asesorHash }, + create: { + email: "asesor@urieljareth.com", + password: asesorHash, + name: "Lorena Soto", + role: "asesor", + }, + }); + + // ── CATEGORIAS (must be created BEFORE servicios for FK) ── + const categorias = [ + { nombre: "SEO", descripcion: "Optimizacion para motores de busqueda", color: "#10b981", orden: 1 }, + { nombre: "Marketing", descripcion: "Estrategia y marketing digital", color: "#6366f1", orden: 2 }, + { nombre: "Paid Media", descripcion: "Publicidad pagada en plataformas digitales", color: "#f59e0b", orden: 3 }, + { nombre: "Desarrollo Web", descripcion: "Diseno y desarrollo de sitios web y ecommerce", color: "#3b82f6", orden: 4 }, + { nombre: "Automatizaciones", descripcion: "Automatizacion de procesos y flujos de trabajo", color: "#8b5cf6", orden: 5 }, + { nombre: "CRM", descripcion: "Gestion de relaciones con clientes", color: "#ec4899", orden: 6 }, + { nombre: "Desarrollo Personalizado", descripcion: "Soluciones a medida y desarrollo custom", color: "#14b8a6", orden: 7 }, + ]; + + const catMap: Record = {}; + for (const cat of categorias) { + const created = await prisma.categoria.upsert({ + where: { nombre: cat.nombre }, + update: { color: cat.color, orden: cat.orden }, + create: cat, + }); + catMap[cat.nombre] = created.id; + } + + const catAlias: Record = { + auditoria: "Marketing", + estrategia: "Marketing", + diseno: "Desarrollo Web", + sitio_web: "Desarrollo Web", + ecommerce: "Desarrollo Web", + ads: "Paid Media", + redes: "Marketing", + seo: "SEO", + contenido: "Marketing", + crm: "CRM", + acompanamiento: "Marketing", + otros: "Desarrollo Personalizado", + }; + + const servicios = [ + { + nombre: "Auditoria en Redes Sociales", + descripcion: "Auditoria completa de presencia en redes sociales", + fase: 0, + tipoPago: "unico", + precioBase: 2990, + tiempoEntrega: "4 - 10 dias", + categoria: "auditoria", + orden: 1, + entregablesDefault: [ + "Revision de presencia en redes sociales", + "Analisis de contenido publicado", + "Evaluacion de engagement", + "Documento de Evaluacion Total de Auditoria", + "Junta de Resultados", + ], + }, + { + nombre: "Auditoria en Sitio Web", + descripcion: "Auditoria tecnica y de rendimiento de sitio web", + fase: 0, + tipoPago: "unico", + precioBase: 2990, + tiempoEntrega: "4 - 10 dias", + categoria: "auditoria", + orden: 2, + entregablesDefault: [ + "Seguridad de dominio (vulnerabilidades)", + "Velocidad de sitio web (core web vital)", + "Diseno responsivo", + "Performance del sitio", + "Revison de SEO On Page y Off Page", + "Chequeo de listado del sitio en listas negras", + "Chequeo de Malware y Codigo Malicioso", + "Revision de Firewall en el Sitio", + "Recomendaciones SEO", + "Revision de Google Search Console", + "Revision de pixeles de Google y FB Ads", + "Documento de Evaluacion Total de Auditoria", + "Junta de Resultados", + ], + }, + { + nombre: "Auditoria en Ecommerce", + descripcion: "Auditoria completa de tienda en linea", + fase: 0, + tipoPago: "unico", + precioBase: 2990, + tiempoEntrega: "4 - 10 dias", + categoria: "auditoria", + orden: 3, + entregablesDefault: [ + "Revision de Seguridad de dominio", + "Revision de Certificado SSL", + "Revision de Metodos de Pago", + "Revision de Metodos de Envio", + "Revision de Velocidad de Sitio Web", + "Revision de Categorizacion de Productos", + "Revision de Filtrado de Productos", + "Revision de Carritos Abandonados", + "Revision de Correos Transaccionales", + "Revision de Pasos de Checkout", + "Documento de Evaluacion Total de Auditoria", + "Junta de Resultados", + ], + }, + { + nombre: "Auditoria de cuentas Google Ads y Meta Ads", + descripcion: "Auditoria de cuentas publicitarias", + fase: 0, + tipoPago: "unico", + precioBase: 2990, + tiempoEntrega: "4 - 10 dias", + categoria: "auditoria", + orden: 4, + entregablesDefault: [ + "Revision de Configuracion General de Google Ads", + "Revision de Configuracion de Facebook/Instagram Ads", + "Revision de la Segmentacion de Publicos Personalizados", + "Revision de Configuracion de Campanas", + "Revision de Configuracion de Grupo de Anuncios", + "Revision de Conversiones y Eventos Personalizados", + "Revision de Integracion e Implementacion de Pixeles", + "Revision de Estrategia de Presupuesto", + "Revision de Anuncios Graficos", + "Revision de Extensiones en los Anuncios", + "Documento de Evaluacion Total de Auditoria", + "Junta de Resultados", + ], + }, + { + nombre: "Estudio Digital de Mercado", + descripcion: "Estudio completo de mercado digital", + fase: 1, + tipoPago: "unico", + precioBase: 4900, + tiempoEntrega: "4 - 10 dias", + categoria: "estrategia", + orden: 5, + entregablesDefault: [ + "Estudio de Competencia Organica", + "Estudio de Competencia Pagada", + "Estudio de Demanda", + "Estudio de Tendencia", + "Estudio de Segmentacion de Mercado", + "Junta de Analisis de Resultados y Conclusiones", + ], + }, + { + nombre: "Diseno de Identidad Corporativa", + descripcion: "Creacion de identidad corporativa completa", + fase: 1, + tipoPago: "unico", + precioBase: 11900, + tiempoEntrega: "7 - 15 dias", + categoria: "diseno", + orden: 6, + entregablesDefault: [ + "Creacion de Logotipo y Favicon", + "Manual de Identidad Corporativa (colores, tipografias, usos)", + "Plantillas para Redes Sociales (posts, historias, carruseles)", + "Plantilla para portada de Redes Sociales", + "Entrega de archivos editables", + "Entrega de Mockups", + "Aplicativos Digitales (firma electronica, diapositivas, wallpaper)", + "Aplicativos Fisicos (tarjetas, hojas membretadas)", + "Presentacion de Propuestas", + "Tres juntas de revision", + ], + }, + { + nombre: "Diseno de Sitio Web (WordPress)", + descripcion: "Diseno y desarrollo de sitio web en WordPress", + fase: 1, + tipoPago: "unico", + precioBase: 35000, + tiempoEntrega: "4 - 8 semanas", + categoria: "sitio_web", + variante: "wordpress", + orden: 7, + entregablesDefault: [ + "Desarrollo y Estructuracion Jerarquica de Sitio Web", + "Creacion de Paginas Basicas (TyC, PyP, Gracias, 404)", + "Optimizacion Avanzada SEO en cada Pagina", + "Sitio adaptado a ordenadores, tabletas y celulares (responsive)", + "Configuracion Avanzada de Velocidad de Carga", + "Diseno y Optimizacion de elementos graficos", + "Integracion y Configuracion de Contacto por WhatsApp y Llamada", + "Configuracion de Plugins de Seguridad", + "Capacitacion Basica de Sitio", + "Poliza de mantenimiento", + "Respaldo de seguridad semanal", + "Creacion y Optimizacion de Blog optimizado para SEO", + "Integracion de Formulario", + "Documento Estadisticas de Sitio Web (mensual)", + "Indexacion en Motores de Busqueda", + "Configuracion de Google Analytics 4, Tag Manager, Google Ads, Pixel y Capi de Facebook", + "Configuracion de Google Search Console", + "Revision de TyC, Politicas y Privacidad", + "Creacion de Textos Comerciales", + "Soporte de Primer y Segundo Nivel", + "Dominio, Certificado SSL, Hosting Incluido (5GB) - 1 Ano", + "Incluye tres revisiones del Proyecto", + ], + }, + { + nombre: "Diseno de Ecommerce (WooCommerce)", + descripcion: "Tienda en linea con WooCommerce", + fase: 1, + tipoPago: "unico", + precioBase: 19900, + tiempoEntrega: "4 - 8 semanas", + categoria: "ecommerce", + variante: "woocommerce", + orden: 8, + entregablesDefault: [ + "Desarrollo y Estructuracion de Ecommerce", + "Creacion de Paginas Basicas (TyC, PyP, Gracias, 404)", + "Optimizacion Avanzada SEO en cada Pagina de Productos y Categorias", + "Diseno Responsivo (UI / UX)", + "Configuracion Avanzada de Velocidad de Carga", + "Diseno y Optimizacion de elementos graficos para cada Pagina", + "Integracion y Configuracion de Contacto por WhatsApp y Llamada", + "Integracion de Formulario", + "Integracion de Plugins de Seguridad", + "Capacitacion Basica de Sitio", + "Integracion de Metodos de Pago", + "Integracion de Metodos de Envio", + "Configuracion Jerarquica de Categoria por Nivel", + "Configuracion de Enlazado de Categorias", + "Configuracion de Carritos Abandonados", + "Documento Estadisticas de Sitio Web (mensual)", + "Indexacion en Motores de Busqueda", + "Configuracion de Google Analytics 4, Tag Manager, Google Ads, Pixel y Capi de Facebook para Ecommerce", + "Configuracion de Google Search Console", + "Incluye tres revisiones del Proyecto", + "Dominio, Certificado SSL, Hosting Incluido (5GB) - 1 Ano", + "Creacion de Textos Comerciales", + "Desarrollo y Estructuracion de Blog", + ], + }, + { + nombre: "Diseno Tienda en Linea (Shopify)", + descripcion: "Tienda en linea con Shopify", + fase: 1, + tipoPago: "unico", + precioBase: 21900, + tiempoEntrega: "4 - 8 semanas", + categoria: "ecommerce", + variante: "shopify", + orden: 9, + entregablesDefault: [ + "Desarrollo y Estructuracion de Ecommerce", + "Configuracion de Paginas Basicas (TyC, AyP, Contacto, Nosotros, 404)", + "Configuracion de Paginas de Ecommerce (Mi cuenta, Inicio de sesion, Politicas)", + "Configuracion y Personalizacion de Template", + "Optimizacion SEO en cada Pagina de Productos y Categorias", + "Diseno Responsivo (UI / UX)", + "Configuracion de Velocidad de Carga en Sitio Web", + "Diseno y Optimizacion de elementos graficos para cada Pagina", + "Integracion y Configuracion de App de Contacto por WhatsApp", + "Integracion de Apps para Metodos de Pago", + "Integracion de Apps para Metodos de Envio", + "Configuracion y Estructura de Menu Principal", + "Soporte de Primer y Segundo Nivel", + "Creacion de Categorias de Tienda", + "Configuracion de Correos Base para Ecommerce", + "Configuracion de Carritos Abandonados", + "Indexacion en Motores de Busqueda", + "Configuracion de Google Search Console", + "Implementacion de Google Analytics 4", + "Capi y Pixel de Facebook", + "Creacion de Textos Comerciales", + "Capacitacion Basica de Tienda", + "Incluye tres revisiones del Proyecto", + "Dominio", + ], + }, + { + nombre: "Creacion y Optimizacion de Redes Sociales", + descripcion: "Creacion y brandeo de perfiles en redes sociales", + fase: 1, + tipoPago: "unico", + precioBase: 2500, + tiempoEntrega: "4 - 10 dias", + categoria: "redes", + orden: 10, + entregablesDefault: [ + "Creacion de Redes Sociales (Facebook, Instagram)", + "Brandeo de las Redes y Optimizacion", + ], + }, + { + nombre: "Creacion y Optimizacion de la Ficha de Google My Business", + descripcion: "Configuracion y optimizacion de perfil de Google", + fase: 1, + tipoPago: "unico", + precioBase: 2000, + tiempoEntrega: "4 - 10 dias", + categoria: "seo", + orden: 11, + entregablesDefault: [ + "Creacion y Configuracion de Perfil de Negocio de Google", + "Optimizacion de Perfil de Negocio de Google", + "Generacion de Mapas Fractales", + "Plantillas para Publicaciones en Perfil de Negocio", + "Reporte de resultados", + ], + }, + { + nombre: "Configuracion y Puesta en Marcha del CRM Bucefalo", + descripcion: "Configuracion inicial de cuenta CRM Bucefalo", + fase: 1, + tipoPago: "unico", + precioBase: 3900, + tiempoEntrega: "4 - 10 dias", + categoria: "crm", + orden: 12, + entregablesDefault: [ + "Configuracion de cuenta CRM Bucefalo", + "Curso de Capacitacion", + "Configuracion de Automatizaciones", + "Configuracion de Pipeline de Oportunidades", + "Configuracion de Campos Personalizados", + "Integracion de Calendario", + "Configuracion de Reportes", + ], + }, + { + nombre: "Configuracion, Optimizacion y Puesta en Marcha de Google Ads y Meta Ads", + descripcion: "Setup completo de ambas plataformas publicitarias", + fase: 1, + tipoPago: "unico", + precioBase: 4000, + tiempoEntrega: "4 - 10 dias", + categoria: "ads", + orden: 13, + entregablesDefault: [ + "Configuracion y estructura de las Campanas de Google Ads", + "Configuracion de Conversiones", + "Configuracion de la Segmentacion de Publicos Personalizados", + "Configuracion de listas de terminos de busqueda negativos", + "Configuracion de listas de terminos busqueda", + "Configuracion de Anuncios publicitarios", + "Configuracion de Extensiones en los anuncios", + "Configuracion de Cuenta Facebook Ads e Instagram Ads", + "Configuracion de Pixeles de Conversion", + "Configuracion de Anuncios Publicitarios para Meta", + "Configuracion de Conversiones Personalizadas", + ], + }, + { + nombre: "Tablero de Inspiracion", + descripcion: "Estrategia creativa para campanas publicitarias", + fase: 2, + tipoPago: "unico", + precioBase: 2900, + tiempoEntrega: "6 - 10 dias", + categoria: "estrategia", + orden: 14, + entregablesDefault: [ + "Estrategia Creativa", + "Segmentacion de Mercado", + "Complexogramo", + "Analisis de Competencia", + "Analisis de los mejores Anuncios Locales, Nacionales e Internacionales", + ], + }, + { + nombre: "Configuracion y Optimizacion Google Ads", + descripcion: "Configuracion y optimizacion mensual de Google Ads", + fase: 2, + tipoPago: "mensual", + precioBase: 4900, + tiempoEntrega: "4 - 10 dias", + categoria: "ads", + orden: 15, + entregablesDefault: [ + "Revision de la Segmentacion de Publicos Personalizados", + "Revision de Listas de Terminos de Busqueda Negativos", + "Revision de Listas de Terminos Busqueda", + "Revision de Anuncios Publicitarios", + "Revision de Anuncios Graficos", + "Una Sesion de Acompanamiento para Google", + ], + }, + { + nombre: "Manejo y Optimizacion de Meta Ads", + descripcion: "Manejo y optimizacion mensual de Meta Ads", + fase: 2, + tipoPago: "mensual", + precioBase: 1500, + tiempoEntrega: "4 - 10 dias", + categoria: "ads", + orden: 16, + entregablesDefault: [ + "Revision de la Configuracion de Conversiones", + "Revision de la Segmentacion de Publicos Personalizados", + "Revision de Conversiones Personalizadas", + "Revision de Anuncios Publicitarios", + "Una Sesion de Acompanamiento para la Creacion de Portafolio Comercial / Facebook", + ], + }, + { + nombre: "Manejo Basico de Redes Sociales", + descripcion: "Manejo mensual basico de redes sociales", + fase: 2, + tipoPago: "mensual", + precioBase: 3000, + tiempoEntrega: "4 - 10 dias", + categoria: "redes", + orden: 17, + entregablesDefault: [ + "Realizacion de Parrilla, Diseno y Programacion", + "3 - 5 Post Mensuales", + ], + }, + { + nombre: "Plan Mensual CRM Bucefalo", + descripcion: "Suscripcion mensual al CRM Bucefalo", + fase: 2, + tipoPago: "mensual", + precioBase: 3900, + tiempoEntrega: "Mensual", + categoria: "crm", + orden: 18, + entregablesDefault: [ + "Uso completo del CRM segun plan contratado", + "Soporte tecnico", + "Actualizaciones del sistema", + ], + }, + { + nombre: "Desarrollo de Plan de Contenido", + descripcion: "Plan estrategico de contenido", + fase: 3, + tipoPago: "unico", + precioBase: 2900, + tiempoEntrega: "6 - 10 dias", + categoria: "contenido", + orden: 19, + entregablesDefault: [ + "Analisis situacional de la marca", + "Objetivos de la marca", + "Analisis de Audiencia y Competencia", + "Estrategias de Contenido", + "Propuesta de Contenido", + "Plan de Acciones", + "Canales de Distribucion", + "Medicion y Metricas", + ], + }, + { + nombre: "Generacion de Contenido", + descripcion: "Generacion mensual de contenido para redes", + fase: 3, + tipoPago: "mensual", + precioBase: 8000, + tiempoEntrega: "Mensual", + categoria: "contenido", + orden: 20, + entregablesDefault: [ + "Manejo de Parrilla Mensual (Facebook, Instagram, Pinterest, YouTube, Tik Tok)", + "Publicaciones Estaticas para Redes Sociales", + "Post para Blog", + "Video YouTube largo, Generacion de Reels, Shorts", + "Escucha Social (analisis de opiniones de clientes)", + "Junta de Reporte mensual", + ], + }, + { + nombre: "Posicionamiento SEO", + descripcion: "Posicionamiento SEO mensual", + fase: 3, + tipoPago: "mensual", + precioBase: 2900, + tiempoEntrega: "4 - 10 dias", + categoria: "seo", + orden: 21, + entregablesDefault: [ + "Posicionamiento de KW (3 palabras clave)", + "Traqueo de las Palabras Clave", + "Creacion de Enlaces Externos al Sitio", + "Citaciones para el Sitio", + "Reporte de resultados (mensual)", + ], + }, + { + nombre: "Estrategia SEO Completa", + descripcion: "Estrategia SEO completa con contenido y backlinks", + fase: 3, + tipoPago: "mensual", + precioBase: 2900, + tiempoEntrega: "Mensual", + categoria: "seo", + orden: 22, + entregablesDefault: [ + "Busqueda y definicion de KW a atacar", + "Definicion de tematica y KW por Mes (calendario semestral)", + "Monitoreo de posiciones en SERPS de KW", + "Busqueda de backlinks de la competencia", + "Gestion con medios y webs para el correcto enlazado", + "Indexacion a Backlinks y monitoreo", + "Generacion de contenido SEO para Post del blog o Landing", + "Generacion de Imagenes y video para ilustracion", + "Generacion de Url (post o Landing) para posicionamiento", + "Optimizacion SEO de todas las Url nuevas", + "Enlazado Interno de todo el sitio web", + "Optimizacion de usabilidad del sitio", + "Optimizacion de Copy", + "Optimizacion de EEAT", + "Optimizacion de enlazado interno", + "Optimizacion de prominencia Semantica", + "Reporte mensual", + ], + }, + { + nombre: "Plan de Acompanamiento Integral", + descripcion: "Servicio integral de acompanamiento estrategico y operativo durante un periodo minimo obligatorio de 9 meses (3 meses operando E3, 3 operando en conjunto y 3 meses operando en acompanamiento)", + fase: 0, + tipoPago: "mensual", + precioBase: 9000, + tiempoEntrega: "Minimo 9 meses", + categoria: "acompanamiento", + orden: 23, + entregablesDefault: [ + "Manejo de Google Ads (Performance Max, Demand Gen, IA Max for Search)", + "Implementacion con first-party data y third-party data", + "Reportes mensuales con analisis de resultados Google Ads", + "Manejo de Meta Ads (Advantage+, campanas por objetivo, remarketing dinamico)", + "Segmentacion y automatizacion con IA", + "Reportes mensuales con analisis de resultados Meta Ads", + ], + }, + ]; + + for (const servicio of servicios) { + const catNombre = catAlias[servicio.categoria] || "Desarrollo Personalizado"; + await prisma.servicioCatalogo.upsert({ + where: { id: servicio.nombre.toLowerCase().replace(/\s+/g, "-").substring(0, 30) + "-" + servicio.orden }, + update: {}, + create: { + nombre: servicio.nombre, + descripcion: servicio.descripcion, + fase: servicio.fase, + tipoPago: servicio.tipoPago, + precioBase: servicio.precioBase, + tiempoEntrega: servicio.tiempoEntrega, + entregablesDefault: servicio.entregablesDefault, + categoriaId: catMap[catNombre] || null, + variante: servicio.variante || null, + orden: servicio.orden, + }, + }); + } + + const allServicios = await prisma.servicioCatalogo.findMany(); + + // ── PAQUETE GENERAL ── + const paquete = await prisma.paquete.upsert({ + where: { id: "paquete-general" }, + update: {}, + create: { + id: "paquete-general", + nombre: "General", + descripcion: "Paquete completo de servicios de marketing digital", + activo: true, + }, + }); + + const fasesGeneral = [ + { nombre: "Auditoria / Acompanamiento", orden: 0 }, + { nombre: "Setup e Infraestructura", orden: 1 }, + { nombre: "Publicidad y Manejo", orden: 2 }, + { nombre: "Contenido y SEO", orden: 3 }, + ]; + + const faseMap: Record = {}; + for (const fg of fasesGeneral) { + const faseId = `fase-general-${fg.orden}`; + const created = await prisma.fasePaquete.upsert({ + where: { id: faseId }, + update: { nombre: fg.nombre, orden: fg.orden }, + create: { + id: faseId, + paqueteId: paquete.id, + nombre: fg.nombre, + orden: fg.orden, + }, + }); + faseMap[fg.orden] = created.id; + } + + const existingLinks = await prisma.servicioPaquete.findMany(); + if (existingLinks.length === 0) { + for (const s of allServicios) { + const fasePaqueteId = faseMap[s.fase]; + if (fasePaqueteId) { + await prisma.servicioPaquete.create({ + data: { + servicioCatalogoId: s.id, + fasePaqueteId, + }, + }).catch(() => {}); + } + } + } + + const bonos = [ + { numero: 1, titulo: "Servicio Centinela Web", descripcion: "30 minutos mensuales en servicios Centinela (Sitio Web)" }, + { numero: 2, titulo: "Workshop Buyer Persona", descripcion: "Workshop Estrategico de Buyer Persona" }, + { numero: 3, titulo: "Workshop Propuesta de Valor", descripcion: "Workshop de Propuestas de Valor y Oferta Irresistible" }, + { numero: 4, titulo: "Membresia Premium", descripcion: "1 ano de Membresia Premium" }, + { numero: 5, titulo: "Mes Gratis Bucefalo", descripcion: "Un mes gratis de Bucefalo CRM, Marketing y Ventas" }, + { numero: 6, titulo: "Script de Ventas", descripcion: "Script de Ventas con mas de 100 complementos" }, + ]; + + for (const bono of bonos) { + await prisma.bono.create({ data: bono }); + } + + const planes = [ + { meses: 3, tasa: 0.077, comision: 2.5, montoMinimo: 300, iva: 0.16 }, + { meses: 6, tasa: 0.107, comision: 2.5, montoMinimo: 600, iva: 0.16 }, + { meses: 9, tasa: 0.137, comision: 2.5, montoMinimo: 900, iva: 0.16 }, + { meses: 12, tasa: 0.167, comision: 2.5, montoMinimo: 1200, iva: 0.16 }, + ]; + + for (const plan of planes) { + await prisma.financiamientoPlan.upsert({ + where: { meses: plan.meses }, + update: {}, + create: plan, + }); + } + + const configs = [ + { + clave: "razon_social", + valor: "URIEL JARETH ALVARADO ORTIZ", + }, + { + clave: "rfc", + valor: "AAOU970201SU7", + }, + { + clave: "domicilio_fiscal", + valor: "EMILIANO ZAPATA S/N, CUAMIO, CUITZEO, MICHOACAN DE OCAMPO CP 58855", + }, + { + clave: "cuenta_nacional", + valor: "012227015788120440", + }, + { + clave: "clabe_interbancaria", + valor: "012227015788120440", + }, + { + clave: "cuenta_internacional", + valor: "Beneficiario: URIEL JARETH ALVARADO ORTIZ\nCLABE: 012227015788120440\nBanco: BBVA", + }, + { + clave: "cuenta_internacional_swift", + valor: "Beneficiario: URIEL JARETH ALVARADO ORTIZ\nCodigo SWIFT / BIC: BCMRMXMMPYM\nCLABE: 012227015788120440\nBanco: BBVA Mexico", + }, + { + clave: "hora_centinela", + valor: "700", + }, + { + clave: "anualidad_hosting", + valor: "5000", + }, + { + clave: "iva", + valor: "0.16", + }, + { + clave: "terminos_condiciones", + valor: "- Esta cotizacion tiene una vigencia de 15 dias habiles.\n- Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.\n- El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.\n- Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.\n- Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.\n- Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.", + }, + { + clave: "no_incluye", + valor: "- Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado. (Estos servicios son Extras, pueden ser realizados por E3 y se cotizan como adicionales mediante cotizacion).\n- Redaccion de entradas de Blog.\n- Integracion de Servicios de terceros ajenos a los cotizados.\n- Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads (En Caso de Requerir el Servicio se Aplicara uso de Hora Centinela).\n- Creacion de Redes Sociales (En Caso de Requerir el Servicio Incluira un Costo Adicional).", + }, + { + clave: "notas_adicionales", + valor: "- El presente proyecto debera tener un responsable oficial, a quien se le compartiran los avances y con quien se mantendra la comunicacion.\n- La Hora Centinela tiene un precio de $700.00 MXN.\n- Nuestros servicios incluyen la entrega de archivos finales listos para su uso (JPG, PNG, PDF, etc.). Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia y no estan incluidos, salvo en proyectos de Branding/Logotipos.\n- SI EL PROYECTO SE PAUSA POR RAZONES AJENAS A URIEL JARETH CONSULTING O FALTA DE CONTINUIDAD, ESTO GENERARA COSTO EXTRA PARA RETOMAR EL PROYECTO (SE CONSIDERARA UN COSTO QUE PUEDE IR DEL 15% AL 30% CON BASE A LA COTIZACION INICIAL).", + }, + { + clave: "color_primario", + valor: "#2563eb", + }, + { + clave: "color_secundario", + valor: "#1e293b", + }, + { + clave: "logo_base64", + valor: "", + }, + ]; + + for (const config of configs) { + await prisma.configuracion.upsert({ + where: { clave: config.clave }, + update: { valor: config.valor }, + create: config, + }); + } + + console.log("Seed completed successfully!"); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/src/app/(app)/catalogo/CatalogoClient.tsx b/src/app/(app)/catalogo/CatalogoClient.tsx new file mode 100644 index 0000000..909f6ba --- /dev/null +++ b/src/app/(app)/catalogo/CatalogoClient.tsx @@ -0,0 +1,748 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { + Plus, + Pencil, + Trash2, + X, + Check, + ChevronDown, + ChevronRight, + Loader2, + Package, + LayoutGrid, + Settings, + GripVertical, + Save, + FileDown, + Upload, +} from "lucide-react"; +import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators"; +import clsx from "clsx"; + +interface Servicio { + id: string; + nombre: string; + descripcion: string | null; + fase: number; + tipoPago: string; + precioBase: number; + tiempoEntrega: string; + entregablesDefault: string[]; + categoriaId: string | null; + categoriaNombre: string; + categoriaColor: string; + variante: string | null; + activo: boolean; + orden: number; +} + +interface Categoria { + id: string; + nombre: string; + descripcion: string | null; + color: string; + activo: boolean; + orden: number; +} + +interface FasePaqueteUI { + id: string; + nombre: string; + orden: number; + servicios: { id: string; nombre: string; precioBase: number; tipoPago: string }[]; +} + +interface PaqueteUI { + id: string; + nombre: string; + descripcion: string | null; + fases: FasePaqueteUI[]; +} + +interface Props { + initialServicios: Servicio[]; + initialCategorias: Categoria[]; + initialPaquetes: PaqueteUI[]; +} + +type Tab = "categorias" | "paquetes" | "servicios"; + +const FASES = FASES_LABELS; + +export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) { + const [tab, setTab] = useState("servicios"); + const [servicios, setServicios] = useState(initialServicios); + const [categorias, setCategorias] = useState(initialCategorias); + const [paquetes, setPaquetes] = useState(initialPaquetes); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const [sRes, cRes, pRes] = await Promise.all([ + fetch("/api/catalogo"), + fetch("/api/categorias"), + fetch("/api/paquetes"), + ]); + if (sRes.ok) setServicios(await sRes.json()); + if (cRes.ok) setCategorias(await cRes.json()); + if (pRes.ok) setPaquetes(await pRes.json()); + } finally { + setLoading(false); + } + }, []); + + const handleImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setImporting(true); + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/import/catalogo", { method: "POST", body: form }); + const data = await res.json(); + if (res.ok) { + let msg = `${data.creados} servicio(s) importado(s).`; + if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`; + if (data.errores?.length) msg += `\n\nErrores:\n${data.errores.join("\n")}`; + alert(msg); + await refresh(); + } else { + alert(data.error || "Error al importar"); + } + } catch { + alert("Error al importar CSV"); + } finally { + setImporting(false); + e.target.value = ""; + } + }; + + const deleteCategoria = async (id: string) => { + const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" }); + if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id)); + else { const e = await res.json(); alert(e.error); } + }; + + const toggleCategoriaActivo = async (cat: Categoria) => { + const res = await fetch(`/api/categorias/${cat.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...cat, activo: !cat.activo }), + }); + if (res.ok) { + const updated = await res.json(); + setCategorias((p) => p.map((c) => (c.id === cat.id ? updated : c))); + } + }; + + const deletePaquete = async (id: string) => { + if (!confirm("Eliminar este paquete y todas sus fases?")) return; + const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" }); + if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id)); + }; + + const handleCreatePaquete = async () => { + const nombre = prompt("Nombre del paquete:"); + if (!nombre) return; + const res = await fetch("/api/paquetes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nombre, fases: [] }), + }); + if (res.ok) { + const created = await res.json(); + setPaquetes((p) => [...p, { ...created, fases: [] }]); + } + }; + + const addFaseToPaquete = async (paqueteId: string) => { + const nombre = prompt("Nombre de la fase:"); + if (!nombre) return; + const pk = paquetes.find((p) => p.id === paqueteId); + const orden = pk ? pk.fases.length : 0; + const res = await fetch(`/api/paquetes/${paqueteId}/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "addFase", nombre, orden }), + }); + if (res.ok) { + const fase = await res.json(); + setPaquetes((p) => + p.map((pk) => + pk.id === paqueteId ? { ...pk, fases: [...pk.fases, { ...fase, servicios: [] }] } : pk + ) + ); + } + }; + + const deleteFase = async (paqueteId: string, faseId: string) => { + if (!confirm("Eliminar esta fase?")) return; + const res = await fetch(`/api/paquetes/${paqueteId}/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "deleteFase", faseId }), + }); + if (res.ok) { + setPaquetes((p) => + p.map((pk) => + pk.id === paqueteId ? { ...pk, fases: pk.fases.filter((f) => f.id !== faseId) } : pk + ) + ); + } + }; + + const removeServFromFase = async (paqueteId: string, faseId: string, servicioId: string) => { + const res = await fetch(`/api/paquetes/${paqueteId}/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "removeServicio", servicioCatalogoId: servicioId, fasePaqueteId: faseId }), + }); + if (res.ok) { + setPaquetes((p) => + p.map((pk) => + pk.id === paqueteId + ? { + ...pk, + fases: pk.fases.map((f) => + f.id === faseId + ? { ...f, servicios: f.servicios.filter((s) => s.id !== servicioId) } + : f + ), + } + : pk + ) + ); + } + }; + + const addServToFase = async (paqueteId: string, faseId: string, servicioId: string) => { + const res = await fetch(`/api/paquetes/${paqueteId}/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "addServicio", servicioCatalogoId: servicioId, fasePaqueteId: faseId }), + }); + if (res.ok) await refresh(); + }; + + const [addModal, setAddModal] = useState<{ paqueteId: string; faseId: string } | null>(null); + const [servicioModal, setServicioModal] = useState(null); + const [showNewServicio, setShowNewServicio] = useState(false); + const [expandedPk, setExpandedPk] = useState>(new Set(paquetes.map((p) => p.id))); + + const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [ + { key: "servicios", label: "Servicios", icon: }, + { key: "categorias", label: "Categorias", icon: }, + { key: "paquetes", label: "Paquetes", icon: }, + ]; + + return ( +
+
+

Catalogo de Servicios

+
+ + + Plantilla CSV + + + + + Exportar CSV + + +
+
+ +
+ {tabs.map((t) => ( + + ))} +
+ + {/* ── TAB: SERVICIOS (por categoria) ── */} + {tab === "servicios" && ( +
+
+

{servicios.filter((s) => s.activo).length} servicios activos

+ +
+ {categorias.filter((c) => c.activo).map((cat) => { + const catServs = servicios.filter((s) => s.categoriaNombre === cat.nombre); + if (!catServs.length) return null; + return ( +
+
+
+

{cat.nombre}

+ ({catServs.length}) +
+
+ + + + + + + + + + + + + {catServs.map((s) => ( + + + + + + + + + ))} + +
ServicioTipoTiempoPrecioEntregablesAcciones
+
{s.nombre}
+ {s.descripcion &&
{s.descripcion}
} +
+ + {s.tipoPago === "unico" ? "Unico" : "Mensual"} + + {s.tiempoEntrega}{formatCurrency(s.precioBase)}{s.entregablesDefault.length} +
+ + +
+
+
+
+ ); + })} +
+ )} + + {/* ── TAB: CATEGORIAS ── */} + {tab === "categorias" && ( +
+ {categorias.map((cat) => ( +
+
+
+
{cat.nombre}
+ {cat.descripcion &&
{cat.descripcion}
} +
+ + {servicios.filter((s) => s.categoriaNombre === cat.nombre).length} servicios + + + +
+ ))} + +
+ )} + + {/* ── TAB: PAQUETES ── */} + {tab === "paquetes" && ( +
+
+ +
+ + {paquetes.map((pk) => { + const isOpen = expandedPk.has(pk.id); + return ( +
+
setExpandedPk((prev) => { const n = new Set(prev); n.has(pk.id) ? n.delete(pk.id) : n.add(pk.id); return n; })} + className="w-full flex items-center gap-3 px-5 py-4 text-left hover:bg-gray-50 cursor-pointer" + > + {isOpen ? : } +
+
{pk.nombre}
+ {pk.descripcion &&
{pk.descripcion}
} +
+ {pk.fases.length} fases + +
+ + {isOpen && ( +
+ {pk.fases.map((fase) => ( +
+
+ {fase.nombre} + ({fase.servicios.length} servicios) +
+ + +
+
+ {fase.servicios.length > 0 ? ( + + + {fase.servicios.map((s) => ( + + + + + + + ))} + +
+
{s.nombre}
+
+ + {s.tipoPago === "unico" ? "Unico" : "Mensual"} + + {formatCurrency(s.precioBase)} + +
+ ) : ( +

Sin servicios asignados

+ )} +
+ ))} + +
+ )} +
+ ); + })} +
+ )} + + {/* ── MODAL: Add service to phase ── */} + {addModal && ( + s.activo)} + onAdd={(servicioId) => { + addServToFase(addModal.paqueteId, addModal.faseId, servicioId); + setAddModal(null); + }} + onClose={() => setAddModal(null)} + /> + )} + + {(servicioModal || showNewServicio) && ( + { + if (servicioModal) { + const res = await fetch(`/api/catalogo/${servicioModal.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + const updated = await res.json(); + setServicios((p) => p.map((s) => (s.id === servicioModal.id ? { ...s, ...updated, categoriaNombre: categorias.find((c) => c.id === data.categoriaId)?.nombre || s.categoriaNombre, categoriaColor: categorias.find((c) => c.id === data.categoriaId)?.color || s.categoriaColor } : s))); + } + } else { + const res = await fetch("/api/catalogo", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (res.ok) { + const created = await res.json(); + const cat = categorias.find((c) => c.id === data.categoriaId); + setServicios((p) => [...p, { ...created, categoriaNombre: cat?.nombre || "Sin categoria", categoriaColor: cat?.color || "#6b7280" }]); + } + } + setServicioModal(null); + setShowNewServicio(false); + }} + onClose={() => { setServicioModal(null); setShowNewServicio(false); }} + /> + )} +
+ ); +} + +function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) { + const [nombre, setNombre] = useState(""); + const [color, setColor] = useState("#6b7280"); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + if (!nombre.trim()) return; + setSaving(true); + try { + const res = await fetch("/api/categorias", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nombre: nombre.trim(), color }), + }); + if (res.ok) { setNombre(""); onCreated(); } + else { const e = await res.json(); alert(e.error); } + } finally { setSaving(false); } + }; + + return ( +
+ setColor(e.target.value)} className="w-8 h-8 rounded cursor-pointer p-0 border-0" /> + setNombre(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSave()} + placeholder="Nueva categoria..." + className="flex-1 px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" + /> + +
+ ); +} + +function AddServicioModal({ + servicios, + onAdd, + onClose, +}: { + servicios: Servicio[]; + onAdd: (id: string) => void; + onClose: () => void; +}) { + const [search, setSearch] = useState(""); + const filtered = servicios.filter( + (s) => + s.nombre.toLowerCase().includes(search.toLowerCase()) || + s.categoriaNombre.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
e.stopPropagation()}> +
+

Agregar servicio a la fase

+ +
+
+ setSearch(e.target.value)} + placeholder="Buscar por nombre o categoria..." + className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20" + autoFocus + /> +
+
+ {filtered.map((s) => ( + + ))} + {filtered.length === 0 &&

Sin resultados

} +
+
+
+ ); +} + +function ServicioFormModal({ + servicio, + categorias, + onSave, + onClose, +}: { + servicio: Servicio | null; + categorias: Categoria[]; + onSave: (data: Record) => void; + onClose: () => void; +}) { + const [form, setForm] = useState({ + nombre: servicio?.nombre || "", + descripcion: servicio?.descripcion || "", + categoriaId: servicio?.categoriaId || "", + tipoPago: servicio?.tipoPago || "unico", + precioBase: servicio?.precioBase ?? 0, + tiempoEntrega: servicio?.tiempoEntrega || "4 - 10 dias", + entregablesDefault: servicio?.entregablesDefault?.join("\n") || "", + variante: servicio?.variante || "", + fase: servicio?.fase ?? 1, + orden: servicio?.orden ?? 0, + activo: servicio?.activo ?? true, + }); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + if (!form.nombre.trim()) return; + setSaving(true); + try { + const entregables = form.entregablesDefault + .split("\n") + .map((e) => e.trim()) + .filter(Boolean); + const cat = categorias.find((c) => c.id === form.categoriaId); + await onSave({ + nombre: form.nombre.trim(), + descripcion: form.descripcion.trim() || null, + categoriaId: form.categoriaId || null, + tipoPago: form.tipoPago, + precioBase: Number(form.precioBase), + tiempoEntrega: form.tiempoEntrega, + entregablesDefault: entregables, + variante: form.variante.trim() || null, + fase: form.fase, + orden: form.orden, + activo: form.activo, + }); + } finally { + setSaving(false); + } + }; + + const update = (key: string, value: unknown) => setForm((p) => ({ ...p, [key]: value })); + + return ( +
+
e.stopPropagation()}> +
+

{servicio ? "Editar Servicio" : "Nuevo Servicio"}

+ +
+
+
+ + update("nombre", e.target.value)} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" /> +
+
+ +