This commit is contained in:
2026-06-10 02:04:37 -06:00
parent 1ba3d8dea4
commit 31cdce03a0
131 changed files with 15922 additions and 106 deletions
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma
+70 -4
View File
@@ -1,5 +1,71 @@
<!-- BEGIN:nextjs-agent-rules --> # AGENTS.md — Cotizador E3
# This is NOT the Next.js you know
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
<!-- END:nextjs-agent-rules -->
| 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<Buffer>` — 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+470
View File
@@ -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 <key>`
**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": "[email protected]", "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": "[email protected]",
"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": "[email protected]",
"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
```
+12
View File
@@ -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"]
View File
Binary file not shown.
Binary file not shown.
+65
View File
@@ -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"},
)
+18
View File
@@ -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()
+33
View File
@@ -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
+37
View File
@@ -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
View File
Binary file not shown.
Binary file not shown.
+446
View File
@@ -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
+289
View File
@@ -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",
},
]
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+77
View File
@@ -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
+35
View File
@@ -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
+57
View File
@@ -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
+41
View File
@@ -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]
+133
View File
@@ -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": "[email protected]",
"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}
+57
View File
@@ -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
+55
View File
@@ -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}
View File
+71
View File
@@ -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,
}
+14
View File
@@ -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]
+285
View File
@@ -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)
+192
View File
@@ -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)
+211
View File
@@ -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()
+49
View File
@@ -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()
+565
View File
@@ -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
+331
View File
@@ -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"},
)
+122
View File
@@ -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
+42
View File
@@ -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",
],
)
+113
View File
@@ -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}
+425
View File
@@ -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",
)
View File
+105
View File
@@ -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}"
+339
View File
@@ -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()
+437
View File
@@ -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 | [email protected] | (445) 182 9943")
c.save()
return buf.getvalue()
+142
View File
@@ -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)
+12
View File
@@ -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
+33
View File
@@ -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:
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ serverExternalPackages: ["pdfkit"],
}; };
export default nextConfig; export default nextConfig;
+2878 -21
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -6,21 +6,43 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "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": { "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", "next": "16.2.4",
"pdfkit": "^0.18.0",
"pg": "^8.20.0",
"prisma": "^7.8.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/pdfkit": "^0.17.6",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.4", "eslint-config-next": "16.2.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5" "typescript": "^5"
} }
} }
+13
View File
@@ -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"],
},
});
@@ -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;
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ServicioCatalogo" DROP COLUMN "categoria";
+3
View File
@@ -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"
+180
View File
@@ -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)
}
+760
View File
@@ -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: "[email protected]" },
update: { password: adminHash },
create: {
email: "[email protected]",
password: adminHash,
name: "Uriel Jareth Alvarado Ortiz",
role: "admin",
},
});
await prisma.user.upsert({
where: { email: "[email protected]" },
update: { password: asesorHash },
create: {
email: "[email protected]",
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<string, string> = {};
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<string, string> = {
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<number, string> = {};
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();
});
+748
View File
@@ -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<Tab>("servicios");
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
const [paquetes, setPaquetes] = useState<PaqueteUI[]>(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<HTMLInputElement>) => {
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<Servicio | null>(null);
const [showNewServicio, setShowNewServicio] = useState(false);
const [expandedPk, setExpandedPk] = useState<Set<string>>(new Set(paquetes.map((p) => p.id)));
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: "servicios", label: "Servicios", icon: <LayoutGrid className="w-4 h-4" /> },
{ key: "categorias", label: "Categorias", icon: <Settings className="w-4 h-4" /> },
{ key: "paquetes", label: "Paquetes", icon: <Package className="w-4 h-4" /> },
];
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Catalogo de Servicios</h1>
<div className="flex items-center gap-3">
<a
href="/api/export/catalogo/plantilla"
download
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors"
>
<FileDown className="w-4 h-4" />
Plantilla CSV
</a>
<label className={`flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors cursor-pointer ${importing ? "opacity-50 pointer-events-none" : ""}`}>
<Upload className="w-4 h-4" />
{importing ? "Importando..." : "Importar CSV"}
<input type="file" accept=".csv" onChange={handleImport} className="hidden" />
</label>
<a
href="/api/export/catalogo"
download
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors"
>
<FileDown className="w-4 h-4" />
Exportar CSV
</a>
<button onClick={refresh} disabled={loading} className="text-xs text-muted hover:text-dark">
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Actualizar"}
</button>
</div>
</div>
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-lg w-fit">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
tab === t.key ? "bg-white shadow text-dark" : "text-muted hover:text-dark"
)}
>
{t.icon}{t.label}
</button>
))}
</div>
{/* ── TAB: SERVICIOS (por categoria) ── */}
{tab === "servicios" && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted">{servicios.filter((s) => s.activo).length} servicios activos</p>
<button
onClick={() => setShowNewServicio(true)}
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark"
>
<Plus className="w-4 h-4" /> Nuevo Servicio
</button>
</div>
{categorias.filter((c) => c.activo).map((cat) => {
const catServs = servicios.filter((s) => s.categoriaNombre === cat.nombre);
if (!catServs.length) return null;
return (
<div key={cat.id} className="mb-6">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: cat.color }} />
<h2 className="font-semibold text-lg">{cat.nombre}</h2>
<span className="text-xs text-muted">({catServs.length})</span>
</div>
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-muted">Servicio</th>
<th className="text-left px-4 py-3 font-medium text-muted">Tipo</th>
<th className="text-left px-4 py-3 font-medium text-muted">Tiempo</th>
<th className="text-right px-4 py-3 font-medium text-muted">Precio</th>
<th className="text-center px-4 py-3 font-medium text-muted">Entregables</th>
<th className="text-center px-4 py-3 font-medium text-muted w-24">Acciones</th>
</tr>
</thead>
<tbody>
{catServs.map((s) => (
<tr key={s.id} className={clsx("border-b border-border hover:bg-gray-50", !s.activo && "opacity-50")}>
<td className="px-4 py-3">
<div className="font-medium">{s.nombre}</div>
{s.descripcion && <div className="text-xs text-muted mt-0.5">{s.descripcion}</div>}
</td>
<td className="px-4 py-3">
<span className={clsx("inline-block px-2 py-0.5 rounded text-xs font-medium", s.tipoPago === "unico" ? "bg-blue-100 text-blue-700" : "bg-green-100 text-green-700")}>
{s.tipoPago === "unico" ? "Unico" : "Mensual"}
</span>
</td>
<td className="px-4 py-3 text-muted text-xs">{s.tiempoEntrega}</td>
<td className="px-4 py-3 text-right font-medium">{formatCurrency(s.precioBase)}</td>
<td className="px-4 py-3 text-center text-muted">{s.entregablesDefault.length}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button onClick={() => setServicioModal(s)} className="p-1.5 text-muted hover:text-primary rounded" title="Editar">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={async () => {
if (!confirm(`Eliminar "${s.nombre}"?`)) return;
const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" });
if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } }
}} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
)}
{/* ── TAB: CATEGORIAS ── */}
{tab === "categorias" && (
<div className="space-y-3">
{categorias.map((cat) => (
<div key={cat.id} className="bg-card-bg rounded-xl border border-border p-4 flex items-center gap-4">
<div className="w-5 h-5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
<div className="flex-1 min-w-0">
<div className="font-medium">{cat.nombre}</div>
{cat.descripcion && <div className="text-xs text-muted">{cat.descripcion}</div>}
</div>
<span className="text-xs text-muted">
{servicios.filter((s) => s.categoriaNombre === cat.nombre).length} servicios
</span>
<button
onClick={() => toggleCategoriaActivo(cat)}
className={clsx("px-3 py-1 rounded text-xs font-medium", cat.activo ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")}
>
{cat.activo ? "Activa" : "Inactiva"}
</button>
<button onClick={() => deleteCategoria(cat.id)} className="p-1.5 text-muted hover:text-red-500 rounded">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<CreateCategoriaInline onCreated={refresh} />
</div>
)}
{/* ── TAB: PAQUETES ── */}
{tab === "paquetes" && (
<div className="space-y-6">
<div className="flex justify-end">
<button onClick={handleCreatePaquete} className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark">
<Plus className="w-4 h-4" /> Nuevo Paquete
</button>
</div>
{paquetes.map((pk) => {
const isOpen = expandedPk.has(pk.id);
return (
<div key={pk.id} className="bg-card-bg rounded-xl border border-border overflow-hidden">
<div
onClick={() => 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 ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<div className="flex-1">
<div className="font-semibold text-lg">{pk.nombre}</div>
{pk.descripcion && <div className="text-xs text-muted">{pk.descripcion}</div>}
</div>
<span className="text-xs text-muted">{pk.fases.length} fases</span>
<button onClick={(e) => { e.stopPropagation(); deletePaquete(pk.id); }} className="p-1.5 text-muted hover:text-red-500 rounded">
<Trash2 className="w-4 h-4" />
</button>
</div>
{isOpen && (
<div className="px-5 pb-4 space-y-4">
{pk.fases.map((fase) => (
<div key={fase.id} className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50">
<span className="font-medium text-sm">{fase.nombre}</span>
<span className="text-xs text-muted">({fase.servicios.length} servicios)</span>
<div className="ml-auto">
<button onClick={() => setAddModal({ paqueteId: pk.id, faseId: fase.id })} className="text-xs text-primary hover:underline mr-3">
+ Agregar servicio
</button>
<button onClick={() => deleteFase(pk.id, fase.id)} className="text-xs text-red-500 hover:underline">
Eliminar fase
</button>
</div>
</div>
{fase.servicios.length > 0 ? (
<table className="w-full text-sm">
<tbody>
{fase.servicios.map((s) => (
<tr key={s.id} className="border-t border-border">
<td className="px-4 py-2">
<div className="font-medium">{s.nombre}</div>
</td>
<td className="px-4 py-2">
<span className={clsx("text-xs px-2 py-0.5 rounded", s.tipoPago === "unico" ? "bg-blue-100 text-blue-700" : "bg-green-100 text-green-700")}>
{s.tipoPago === "unico" ? "Unico" : "Mensual"}
</span>
</td>
<td className="px-4 py-2 text-right font-medium text-sm">{formatCurrency(s.precioBase)}</td>
<td className="px-4 py-2 w-10">
<button onClick={() => removeServFromFase(pk.id, fase.id, s.id)} className="text-muted hover:text-red-500">
<X className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="px-4 py-3 text-xs text-muted">Sin servicios asignados</p>
)}
</div>
))}
<button onClick={() => addFaseToPaquete(pk.id)} className="text-sm text-primary hover:underline flex items-center gap-1">
<Plus className="w-3.5 h-3.5" /> Agregar fase
</button>
</div>
)}
</div>
);
})}
</div>
)}
{/* ── MODAL: Add service to phase ── */}
{addModal && (
<AddServicioModal
servicios={servicios.filter((s) => s.activo)}
onAdd={(servicioId) => {
addServToFase(addModal.paqueteId, addModal.faseId, servicioId);
setAddModal(null);
}}
onClose={() => setAddModal(null)}
/>
)}
{(servicioModal || showNewServicio) && (
<ServicioFormModal
servicio={servicioModal}
categorias={categorias}
onSave={async (data) => {
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); }}
/>
)}
</div>
);
}
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 (
<div className="flex items-center gap-3 bg-card-bg rounded-xl border border-dashed border-border p-4">
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-8 h-8 rounded cursor-pointer p-0 border-0" />
<input
type="text"
value={nombre}
onChange={(e) => 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"
/>
<button onClick={handleSave} disabled={saving || !nombre.trim()} className="flex items-center gap-2 px-3 py-2 bg-primary text-white rounded-lg text-sm disabled:opacity-50">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Crear
</button>
</div>
);
}
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 (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[70vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
<h3 className="font-semibold">Agregar servicio a la fase</h3>
<button onClick={onClose}><X className="w-5 h-5 text-muted" /></button>
</div>
<div className="px-5 py-3 border-b border-border">
<input
type="text"
value={search}
onChange={(e) => 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
/>
</div>
<div className="overflow-y-auto flex-1">
{filtered.map((s) => (
<button
key={s.id}
onClick={() => onAdd(s.id)}
className="w-full text-left px-5 py-3 hover:bg-gray-50 border-b border-border/50 flex items-center gap-3"
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: s.categoriaColor }} />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{s.nombre}</div>
<div className="text-xs text-muted">{s.categoriaNombre} · {s.tipoPago === "unico" ? "Unico" : "Mensual"}</div>
</div>
<span className="text-sm font-medium">{formatCurrency(s.precioBase)}</span>
</button>
))}
{filtered.length === 0 && <p className="text-center text-muted text-sm py-8">Sin resultados</p>}
</div>
</div>
</div>
);
}
function ServicioFormModal({
servicio,
categorias,
onSave,
onClose,
}: {
servicio: Servicio | null;
categorias: Categoria[];
onSave: (data: Record<string, unknown>) => 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 (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
<h3 className="font-semibold">{servicio ? "Editar Servicio" : "Nuevo Servicio"}</h3>
<button onClick={onClose}><X className="w-5 h-5 text-muted" /></button>
</div>
<div className="overflow-y-auto flex-1 p-5 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Nombre *</label>
<input type="text" value={form.nombre} onChange={(e) => 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" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Descripcion</label>
<textarea value={form.descripcion} onChange={(e) => update("descripcion", e.target.value)} rows={2} 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 resize-none" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Categoria</label>
<select value={form.categoriaId} onChange={(e) => update("categoriaId", 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 bg-white">
<option value="">Sin categoria</option>
{categorias.map((c) => <option key={c.id} value={c.id}>{c.nombre}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tipo de Pago</label>
<select value={form.tipoPago} onChange={(e) => update("tipoPago", 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 bg-white">
<option value="unico">Unico</option>
<option value="mensual">Mensual</option>
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Precio Base</label>
<input type="number" value={form.precioBase} onChange={(e) => update("precioBase", Number(e.target.value))} min={0} 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" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Fase</label>
<select value={form.fase} onChange={(e) => update("fase", Number(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 bg-white">
{Object.entries(FASES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tiempo Entrega</label>
<input type="text" value={form.tiempoEntrega} onChange={(e) => update("tiempoEntrega", 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" />
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Entregables (uno por linea)</label>
<textarea value={form.entregablesDefault} onChange={(e) => update("entregablesDefault", e.target.value)} rows={4} placeholder="Entregable 1&#10;Entregable 2&#10;..." 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 resize-none font-mono text-xs" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Variante</label>
<input type="text" value={form.variante} onChange={(e) => update("variante", e.target.value)} placeholder="Ej: WooCommerce, Shopify..." 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" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Orden</label>
<input type="number" value={form.orden} onChange={(e) => update("orden", Number(e.target.value))} min={0} 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" />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.activo} onChange={(e) => update("activo", e.target.checked)} className="rounded border-border" />
Activo
</label>
</div>
<div className="px-5 py-4 border-t border-border flex justify-end gap-3">
<button onClick={onClose} className="px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50">Cancelar</button>
<button onClick={handleSave} disabled={saving || !form.nombre.trim()} className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-primary-dark">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{servicio ? "Guardar Cambios" : "Crear Servicio"}
</button>
</div>
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { prisma } from "@/lib/db";
import { CatalogoClient } from "./CatalogoClient";
export const dynamic = "force-dynamic";
export default async function CatalogoPage() {
const [servicios, categorias, paquetes] = await Promise.all([
prisma.servicioCatalogo.findMany({
orderBy: [{ fase: "asc" }, { orden: "asc" }],
include: { categoriaRel: true, paquetes: { include: { fasePaquete: true } } },
}),
prisma.categoria.findMany({ orderBy: { orden: "asc" } }),
prisma.paquete.findMany({
where: { activo: true },
include: {
fases: {
orderBy: { orden: "asc" },
include: {
servicios: { include: { servicio: true } },
},
},
},
orderBy: { createdAt: "asc" },
}),
]);
return (
<CatalogoClient
initialServicios={servicios.map((s) => ({
id: s.id,
nombre: s.nombre,
descripcion: s.descripcion,
fase: s.fase,
tipoPago: s.tipoPago,
precioBase: s.precioBase,
tiempoEntrega: s.tiempoEntrega,
entregablesDefault: Array.isArray(s.entregablesDefault) ? (s.entregablesDefault as string[]) : [],
categoriaId: s.categoriaId,
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
categoriaColor: s.categoriaRel?.color || "#6b7280",
variante: s.variante,
activo: s.activo,
orden: s.orden,
}))}
initialCategorias={categorias}
initialPaquetes={paquetes.map((p) => ({
id: p.id,
nombre: p.nombre,
descripcion: p.descripcion,
fases: p.fases.map((f) => ({
id: f.id,
nombre: f.nombre,
orden: f.orden,
servicios: f.servicios.map((sp) => sp.servicio),
})),
}))}
/>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Search } from "lucide-react";
import { NuevaCotizacionClienteButton } from "./NuevaCotizacionClienteButton";
interface Cliente {
id: string;
nombre: string;
empresa: string | null;
email: string | null;
telefono: string | null;
cotizaciones: { id: string }[];
}
export function ClientesList({ clientes }: { clientes: Cliente[] }) {
const [search, setSearch] = useState("");
const filtered = clientes.filter((c) => {
if (!search) return true;
const q = search.toLowerCase();
return (
c.nombre.toLowerCase().includes(q) ||
(c.empresa || "").toLowerCase().includes(q) ||
(c.email || "").toLowerCase().includes(q)
);
});
return (
<>
<div className="mb-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar por nombre, empresa, email..."
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-center text-muted py-8">
{search ? "Sin resultados para esa busqueda" : "No hay clientes"}
</p>
) : (
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-gray-50">
<th className="text-left px-5 py-3 font-medium text-muted">
Nombre
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Empresa
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Email
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Telefono
</th>
<th className="text-center px-5 py-3 font-medium text-muted">
Cotizaciones
</th>
<th className="text-center px-5 py-3 font-medium text-muted w-16" />
</tr>
</thead>
<tbody>
{filtered.map((c) => (
<tr
key={c.id}
className="border-b border-border hover:bg-gray-50"
>
<td className="px-5 py-3 font-medium">{c.nombre}</td>
<td className="px-5 py-3">{c.empresa || "-"}</td>
<td className="px-5 py-3 text-muted">{c.email || "-"}</td>
<td className="px-5 py-3 text-muted">{c.telefono || "-"}</td>
<td className="px-5 py-3 text-center">
<span className="inline-block px-2 py-0.5 bg-primary-light text-primary rounded text-xs font-medium">
{c.cotizaciones.length}
</span>
</td>
<td className="px-5 py-3 text-center">
<NuevaCotizacionClienteButton
clienteNombre={c.nombre}
clienteEmpresa={c.empresa || ""}
clienteEmail={c.email || ""}
clienteTelefono={c.telefono || ""}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
@@ -0,0 +1,39 @@
"use client";
import { useRouter } from "next/navigation";
import { Plus } from "lucide-react";
import { useCotizacionStore } from "@/lib/store";
export function NuevaCotizacionClienteButton({
clienteNombre,
clienteEmpresa,
clienteEmail,
clienteTelefono,
}: {
clienteNombre: string;
clienteEmpresa: string;
clienteEmail: string;
clienteTelefono: string;
}) {
const router = useRouter();
const store = useCotizacionStore();
const handleClick = () => {
store.resetDraft();
store.setField("clienteNombre", clienteNombre);
store.setField("clienteEmpresa", clienteEmpresa);
store.setField("clienteEmail", clienteEmail);
store.setField("clienteTelefono", clienteTelefono);
router.push("/cotizaciones/nueva");
};
return (
<button
onClick={handleClick}
className="p-1.5 text-muted hover:text-primary hover:bg-primary/5 rounded transition-colors"
title="Nueva cotizacion"
>
<Plus className="w-4 h-4" />
</button>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function ClientesPage() {
const clientes = await prisma.cliente.findMany({
orderBy: { createdAt: "desc" },
include: { cotizaciones: { select: { id: true } } },
});
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold">Clientes</h1>
<p className="text-muted text-sm mt-1">
{clientes.length} clientes registrados
</p>
</div>
</div>
{clientes.length === 0 ? (
<div className="bg-card-bg rounded-xl border border-border p-12 text-center">
<h2 className="text-lg font-semibold mb-2">No hay clientes</h2>
<p className="text-muted mb-4">
Los clientes se crean automaticamente al generar cotizaciones
</p>
</div>
) : (
<ClientesListWrapper clientes={clientes} />
)}
</div>
);
}
import { ClientesList } from "./ClientesList";
function ClientesListWrapper({
clientes,
}: {
clientes: {
id: string;
nombre: string;
empresa: string | null;
email: string | null;
telefono: string | null;
cotizaciones: { id: string }[];
}[];
}) {
return <ClientesList clientes={clientes} />;
}
@@ -0,0 +1,230 @@
"use client";
import { useState, useRef } from "react";
import { Save, Upload, Trash2, Loader2, Image } from "lucide-react";
const SECTIONS = [
{
title: "Branding",
icon: "🎨",
fields: [
{ key: "color_primario", label: "Color Primario", type: "color" },
{ key: "color_secundario", label: "Color Secundario", type: "color" },
],
hasLogo: true,
},
{
title: "Datos Fiscales",
icon: "📋",
fields: [
{ key: "razon_social", label: "Razon Social", type: "text" },
{ key: "rfc", label: "RFC", type: "text" },
{ key: "domicilio_fiscal", label: "Domicilio Fiscal", type: "text" },
],
},
{
title: "Datos Bancarios Nacionales",
icon: "🏦",
fields: [
{ key: "cuenta_nacional", label: "Cuenta", type: "text" },
{ key: "clabe_interbancaria", label: "CLABE Interbancaria", type: "text" },
],
},
{
title: "Datos Bancarios Internacionales",
icon: "🌎",
fields: [
{ key: "cuenta_internacional", label: "Transferencia Internacional (ABA)", type: "textarea" },
{ key: "cuenta_internacional_swift", label: "Transferencia Internacional (SWIFT)", type: "textarea" },
],
},
{
title: "Configuracion del Sistema",
icon: "⚙️",
fields: [
{ key: "hora_centinela", label: "Precio Hora Centinela (MXN)", type: "text" },
{ key: "anualidad_hosting", label: "Anualidad Hosting (MXN)", type: "text" },
{ key: "iva", label: "IVA (decimal)", type: "text" },
],
},
{
title: "Textos Legales",
icon: "📜",
fields: [
{ key: "terminos_condiciones", label: "Terminos y Condiciones", type: "textarea" },
{ key: "no_incluye", label: "Que No Incluye", type: "textarea" },
{ key: "notas_adicionales", label: "Notas Adicionales", type: "textarea" },
],
},
];
export function ConfiguracionClient({ initial }: { initial: Record<string, string> }) {
const [config, setConfig] = useState<Record<string, string>>(initial);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleChange = (key: string, value: string) => {
setConfig((prev) => ({ ...prev, [key]: value }));
setSaved(false);
};
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 500 * 1024) {
alert("El logo debe ser menor a 500KB");
return;
}
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
handleChange("logo_base64", `${file.type}:${base64}`);
};
reader.readAsDataURL(file);
};
const handleRemoveLogo = () => {
handleChange("logo_base64", "");
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch("/api/configuracion", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
alert("Error al guardar");
}
} catch {
alert("Error al guardar");
} finally {
setSaving(false);
}
};
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold">Configuracion</h1>
<p className="text-muted text-sm mt-1">Personaliza el branding y datos de tu empresa</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark disabled:opacity-50 transition-colors font-medium text-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? "Guardando..." : saved ? "Guardado!" : "Guardar Cambios"}
</button>
</div>
<div className="space-y-6">
{SECTIONS.map((section) => (
<div key={section.title} className="bg-card-bg rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border flex items-center gap-2">
<span>{section.icon}</span>
<h2 className="font-semibold">{section.title}</h2>
</div>
<div className="p-5 space-y-4">
{section.hasLogo && (
<div>
<label className="block text-sm font-medium text-muted mb-2">Logo de la Empresa</label>
<div className="flex items-start gap-4">
<div className="w-24 h-24 border-2 border-dashed border-border rounded-lg flex items-center justify-center bg-gray-50 shrink-0 overflow-hidden">
{config.logo_base64 ? (
<img
src={config.logo_base64.includes(":") ? `data:${config.logo_base64.replace(":", ";base64,")}` : `data:image/png;base64,${config.logo_base64}`}
alt="Logo"
className="w-full h-full object-contain p-1"
/>
) : (
<Image className="w-8 h-8 text-muted" />
)}
</div>
<div className="flex-1 space-y-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
onChange={handleLogoUpload}
className="hidden"
/>
<div className="flex gap-2">
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors"
>
<Upload className="w-4 h-4" />
Subir Logo
</button>
{config.logo_base64 && (
<button
onClick={handleRemoveLogo}
className="flex items-center gap-2 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 className="w-4 h-4" />
Eliminar
</button>
)}
</div>
<p className="text-xs text-muted">PNG, JPG, SVG o WebP. Maximo 500KB.</p>
</div>
</div>
</div>
)}
{section.fields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-muted mb-1">{field.label}</label>
{field.type === "color" ? (
<div className="flex items-center gap-3">
<input
type="color"
value={config[field.key] || "#2563eb"}
onChange={(e) => handleChange(field.key, e.target.value)}
className="w-10 h-10 rounded-lg border border-border cursor-pointer p-0.5"
/>
<input
type="text"
value={config[field.key] || ""}
onChange={(e) => handleChange(field.key, e.target.value)}
className="w-32 px-3 py-2 border border-border rounded-lg text-sm font-mono"
/>
<div
className="w-8 h-8 rounded-full border border-border"
style={{ backgroundColor: config[field.key] || "#2563eb" }}
/>
</div>
) : field.type === "textarea" ? (
<textarea
value={config[field.key] || ""}
onChange={(e) => handleChange(field.key, e.target.value)}
rows={6}
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"
/>
) : (
<input
type="text"
value={config[field.key] || ""}
onChange={(e) => handleChange(field.key, 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"
/>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { prisma } from "@/lib/db";
import { ConfiguracionClient } from "./ConfiguracionClient";
export const dynamic = "force-dynamic";
export default async function ConfiguracionPage() {
const configs = await prisma.configuracion.findMany();
const map: Record<string, string> = {};
for (const c of configs) map[c.clave] = c.valor;
return <ConfiguracionClient initial={map} />;
}
@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Pencil, Search } from "lucide-react";
import { formatDate, formatCurrency } from "@/lib/calculators";
import { EstadoBadge } from "@/components/EstadoBadge";
import { ListDeleteButton } from "./ListDeleteButton";
interface Cotizacion {
id: string;
numero: string;
estado: string;
fecha: Date;
cliente: { nombre: string; empresa: string | null };
asesor: { name: string };
servicios: { tipoPago: string; seleccionado: boolean; precio: number }[];
}
export function CotizacionesList({
cotizaciones,
}: {
cotizaciones: Cotizacion[];
}) {
const [search, setSearch] = useState("");
const filtered = cotizaciones.filter((cot) => {
if (!search) return true;
const q = search.toLowerCase();
return (
cot.numero.toLowerCase().includes(q) ||
cot.cliente.nombre.toLowerCase().includes(q) ||
(cot.cliente.empresa || "").toLowerCase().includes(q) ||
cot.asesor.name.toLowerCase().includes(q) ||
cot.estado.toLowerCase().includes(q)
);
});
return (
<>
<div className="mb-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar por numero, cliente, asesor..."
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
/>
</div>
</div>
{filtered.length === 0 ? (
<p className="text-center text-muted py-8">
{search ? "Sin resultados para esa busqueda" : "No hay cotizaciones"}
</p>
) : (
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-gray-50">
<th className="text-left px-5 py-3 font-medium text-muted">
No. Cotizacion
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Cliente
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Asesor
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Fecha
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Estado
</th>
<th className="text-right px-5 py-3 font-medium text-muted">
Total Unico
</th>
<th className="text-right px-5 py-3 font-medium text-muted">
Total Mensual
</th>
<th className="text-center px-5 py-3 font-medium text-muted">
Acciones
</th>
</tr>
</thead>
<tbody>
{filtered.map((cot) => {
const totalUnico = cot.servicios
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
.reduce((sum, s) => sum + s.precio, 0);
const totalMensual = cot.servicios
.filter((s) => s.tipoPago === "mensual" && s.seleccionado)
.reduce((sum, s) => sum + s.precio, 0);
return (
<tr
key={cot.id}
className="border-b border-border hover:bg-gray-50 transition-colors"
>
<td className="px-5 py-3">
<Link
href={`/cotizaciones/${cot.id}`}
className="text-primary font-medium hover:underline"
>
{cot.numero}
</Link>
</td>
<td className="px-5 py-3">
<div>
{cot.cliente.empresa || cot.cliente.nombre}
</div>
<div className="text-xs text-muted">
{cot.cliente.nombre}
</div>
</td>
<td className="px-5 py-3 text-muted">
{cot.asesor.name}
</td>
<td className="px-5 py-3 text-muted">
{formatDate(cot.fecha)}
</td>
<td className="px-5 py-3">
<EstadoBadge estado={cot.estado} />
</td>
<td className="px-5 py-3 text-right font-medium">
{totalUnico > 0 ? formatCurrency(totalUnico) : "-"}
</td>
<td className="px-5 py-3 text-right font-medium">
{totalMensual > 0
? formatCurrency(totalMensual)
: "-"}
</td>
<td className="px-5 py-3">
<div className="flex items-center justify-center gap-3">
<Link
href={`/cotizaciones/${cot.id}/editar`}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Pencil className="w-3 h-3" />
Editar
</Link>
<ListDeleteButton id={cot.id} />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function ListDeleteButton({ id }: { id: string }) {
const [deleting, setDeleting] = useState(false);
const router = useRouter();
const handleDelete = async () => {
if (!confirm("Eliminar esta cotizacion?")) return;
setDeleting(true);
try {
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
if (res.ok) router.refresh();
else alert("Error al eliminar");
} catch {
alert("Error al eliminar");
} finally {
setDeleting(false);
}
};
return (
<button
onClick={handleDelete}
disabled={deleting}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
{deleting ? "..." : "Eliminar"}
</button>
);
}
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const OPCIONES = [
{ value: "borrador", label: "Borrador" },
{ value: "enviada", label: "Enviada" },
{ value: "aprobada", label: "Aprobada" },
{ value: "rechazada", label: "Rechazada" },
];
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleChange = async (nuevoEstado: string) => {
if (nuevoEstado === estado) return;
setLoading(true);
try {
const res = await fetch(`/api/cotizaciones/${cotizacionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado: nuevoEstado }),
});
if (res.ok) router.refresh();
else alert("Error al cambiar estado");
} finally {
setLoading(false);
}
};
return (
<select
value={estado}
onChange={(e) => handleChange(e.target.value)}
disabled={loading}
className="text-xs font-medium px-2 py-1 rounded-full border border-border bg-white focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
>
{OPCIONES.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
@@ -0,0 +1,70 @@
"use client";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function DeleteCotizacionButton({
cotizacionId,
cotizacionNumero,
}: {
cotizacionId: string;
cotizacionNumero: string;
}) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
setDeleting(true);
try {
const res = await fetch(`/api/cotizaciones/${cotizacionId}`, {
method: "DELETE",
});
if (res.ok) {
router.push("/cotizaciones");
} else {
alert("Error al eliminar");
setConfirming(false);
}
} catch {
alert("Error al eliminar");
setConfirming(false);
} finally {
setDeleting(false);
}
};
if (confirming) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-red-600 font-medium">
Eliminar {cotizacionNumero}?
</span>
<button
onClick={handleDelete}
disabled={deleting}
className="px-3 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Eliminando..." : "Si, eliminar"}
</button>
<button
onClick={() => setConfirming(false)}
className="px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
>
Cancelar
</button>
</div>
);
}
return (
<button
onClick={() => setConfirming(true)}
className="flex items-center gap-2 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
Eliminar
</button>
);
}
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { formatCurrency, IVA_RATE } from "@/lib/calculators";
interface ServicioItem {
id: string;
nombre: string;
precio: number;
}
interface PreciosEditablesProps {
cotizacionId: string;
serviciosUnicos: ServicioItem[];
serviciosMensuales: ServicioItem[];
}
const INPUT_CLS =
"w-28 px-2 py-1 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary";
export function PreciosEditables({
cotizacionId,
serviciosUnicos,
serviciosMensuales,
}: PreciosEditablesProps) {
const [unicos, setUnicos] = useState(serviciosUnicos);
const [mensuales, setMensuales] = useState(serviciosMensuales);
const [saving, setSaving] = useState<string | null>(null);
const totalUnico = unicos.reduce((s, x) => s + x.precio, 0);
const totalMensual = mensuales.reduce((s, x) => s + x.precio, 0);
const handlePrecioChange = async (
servicioId: string,
nuevoPrecio: number,
tipo: "unico" | "mensual"
) => {
const lista = tipo === "unico" ? unicos : mensuales;
const setter = tipo === "unico" ? setUnicos : setMensuales;
const prev = lista;
setter(lista.map((s) => (s.id === servicioId ? { ...s, precio: nuevoPrecio } : s)));
setSaving(servicioId);
try {
const res = await fetch(`/api/cotizaciones/${cotizacionId}/precio`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ servicioId, precio: nuevoPrecio }),
});
if (!res.ok) setter(prev);
} catch {
setter(prev);
} finally {
setSaving(null);
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="bg-card-bg rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold">Pago Unico</h3>
</div>
<div className="p-5">
{unicos.length === 0 ? (
<p className="text-muted text-sm">Sin servicios</p>
) : (
<div className="space-y-2">
{unicos.map((s) => (
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
<span className="flex-1 truncate">{s.nombre}</span>
<div className="flex items-center gap-1 shrink-0">
<span className="text-muted text-xs">$</span>
<input
type="number"
value={s.precio}
onChange={(e) =>
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "unico")
}
className={INPUT_CLS}
/>
{saving === s.id && (
<span className="text-xs text-muted">...</span>
)}
</div>
</div>
))}
</div>
)}
<div className="border-t border-border mt-3 pt-3 space-y-1">
<div className="flex justify-between text-sm">
<span>Subtotal</span>
<span>{formatCurrency(totalUnico)}</span>
</div>
<div className="flex justify-between text-sm text-muted">
<span>IVA (16%)</span>
<span>{formatCurrency(totalUnico * IVA_RATE)}</span>
</div>
<div className="flex justify-between text-sm font-bold pt-2 border-t border-border">
<span>Total</span>
<span className="text-primary">
{formatCurrency(totalUnico * (1 + IVA_RATE))}
</span>
</div>
</div>
</div>
</div>
<div className="bg-card-bg rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold">Pago Mensual</h3>
</div>
<div className="p-5">
{mensuales.length === 0 ? (
<p className="text-muted text-sm">Sin servicios</p>
) : (
<div className="space-y-2">
{mensuales.map((s) => (
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
<span className="flex-1 truncate">{s.nombre}</span>
<div className="flex items-center gap-1 shrink-0">
<span className="text-muted text-xs">$</span>
<input
type="number"
value={s.precio}
onChange={(e) =>
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "mensual")
}
className={INPUT_CLS}
/>
{saving === s.id && (
<span className="text-xs text-muted">...</span>
)}
</div>
</div>
))}
</div>
)}
<div className="border-t border-border mt-3 pt-3 space-y-1">
<div className="flex justify-between text-sm">
<span>Subtotal</span>
<span>{formatCurrency(totalMensual)}</span>
</div>
<div className="flex justify-between text-sm text-muted">
<span>IVA (16%)</span>
<span>{formatCurrency(totalMensual * IVA_RATE)}</span>
</div>
<div className="flex justify-between text-sm font-bold pt-2 border-t border-border">
<span>Total</span>
<span className="text-primary">
{formatCurrency(totalMensual * (1 + IVA_RATE))}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
import { CotizacionForm } from "@/components/CotizacionForm";
import { prisma } from "@/lib/db";
import { notFound } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function EditarCotizacionPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const [cot, servicios, asesores] = await Promise.all([
prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
}),
prisma.servicioCatalogo.findMany({ where: { activo: true }, orderBy: { orden: "asc" }, include: { categoriaRel: true } }),
prisma.user.findMany({ orderBy: { name: "asc" } }),
]);
if (!cot) notFound();
const existingData = {
id: cot.id,
numero: cot.numero,
clienteNombre: cot.cliente.nombre,
clienteEmpresa: cot.cliente.empresa || "",
clienteEmail: cot.cliente.email || "",
clienteTelefono: cot.cliente.telefono || "",
asesorId: cot.asesorId,
asesorNombre: cot.asesor.name,
fecha: cot.fecha,
vigencia: cot.vigencia,
moneda: cot.moneda,
tipoCambio: cot.tipoCambio,
proyecto: cot.proyecto,
esquemaPago: cot.esquemaPago,
incluirBonos: cot.incluirBonos,
incluirFinanciamiento: cot.incluirFinanciamiento,
observaciones: cot.observaciones || "",
planBucefaloNivel: cot.planBucefalo?.nivel || null,
servicios: cot.servicios.map((s) => ({
catalogoId: s.servicioCatalogoId,
nombre: s.servicioCatalogo?.nombre || "Servicio",
fase: s.fase,
tipoPago: s.tipoPago,
precio: s.precio,
tiempoEntrega: s.tiempoEntrega,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
})),
estado: cot.estado,
};
return (
<CotizacionForm
mode="edit"
existingData={existingData}
servicios={servicios.map((s) => ({
...s,
entregablesDefault: s.entregablesDefault as string[],
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
}))}
asesores={asesores}
/>
);
}
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/db";
import { formatDate, formatCurrency, IVA_RATE } from "@/lib/calculators";
import Link from "next/link";
import { ArrowLeft, Pencil } from "lucide-react";
import { notFound } from "next/navigation";
import { ExportExcelButtonSaved, ExportPDFButtonSaved } from "@/components/ExportButtons";
import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables";
export const dynamic = "force-dynamic";
export default async function CotizacionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
},
});
if (!cot) notFound();
const serviciosUnicos = cot.servicios
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
.map((s) => ({
id: s.id,
nombre: s.servicioCatalogo?.nombre || "Servicio",
precio: s.precio,
}));
const serviciosMensuales = cot.servicios
.filter((s) => s.tipoPago === "mensual" && s.seleccionado)
.map((s) => ({
id: s.id,
nombre: s.servicioCatalogo?.nombre || "Servicio",
precio: s.precio,
}));
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/cotizaciones"
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold">{cot.numero}</h1>
<p className="text-muted text-sm">
{cot.cliente.empresa || cot.cliente.nombre} | {cot.proyecto}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<ExportPDFButtonSaved cotizacionId={cot.id} />
<ExportExcelButtonSaved cotizacionId={cot.id} />
<Link
href={`/cotizaciones/${cot.id}/editar`}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
>
<Pencil className="w-4 h-4" />
Editar
</Link>
<DeleteCotizacionButton cotizacionId={cot.id} cotizacionNumero={cot.numero} />
</div>
</div>
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<h2 className="font-semibold text-lg mb-4">Informacion de la Cotizacion</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted">Cliente</span>
<p className="font-medium">{cot.cliente.nombre}</p>
</div>
<div>
<span className="text-muted">Empresa</span>
<p className="font-medium">{cot.cliente.empresa || "-"}</p>
</div>
<div>
<span className="text-muted">Asesor</span>
<p className="font-medium">{cot.asesor.name}</p>
</div>
<div>
<span className="text-muted">Fecha</span>
<p className="font-medium">{formatDate(cot.fecha)}</p>
</div>
<div>
<span className="text-muted">Vigencia</span>
<p className="font-medium">{formatDate(cot.vigencia)}</p>
</div>
<div>
<span className="text-muted">Moneda</span>
<p className="font-medium">{cot.moneda}</p>
</div>
<div>
<span className="text-muted">Esquema</span>
<p className="font-medium">{cot.esquemaPago}</p>
</div>
<div>
<span className="text-muted">Estado</span>
<div className="mt-0.5">
<CambiarEstadoButtons cotizacionId={cot.id} estado={cot.estado} />
</div>
</div>
</div>
</div>
<PreciosEditables
cotizacionId={cot.id}
serviciosUnicos={serviciosUnicos}
serviciosMensuales={serviciosMensuales}
/>
{cot.observaciones && (
<div className="bg-card-bg rounded-xl border border-border p-5">
<h3 className="font-semibold mb-2">Observaciones</h3>
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
</div>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { CotizacionForm } from "@/components/CotizacionForm";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export default async function NuevaCotizacionPage() {
const [servicios, asesores, clientes, paquetes] = await Promise.all([
prisma.servicioCatalogo.findMany({
where: { activo: true },
orderBy: { orden: "asc" },
include: { categoriaRel: true },
}),
prisma.user.findMany({ orderBy: { name: "asc" } }),
prisma.cliente.findMany({ orderBy: { nombre: "asc" } }),
prisma.paquete.findMany({
where: { activo: true },
include: {
fases: {
orderBy: { orden: "asc" },
include: { servicios: { include: { servicio: true } } },
},
},
orderBy: { createdAt: "asc" },
}),
]);
const lastCot = await prisma.cotizacion.findFirst({
orderBy: { createdAt: "desc" },
select: { numero: true },
});
let nextSeq = 1;
if (lastCot) {
const match = lastCot.numero.match(/(\d+)$/);
if (match) nextSeq = parseInt(match[1]) + 1;
}
return (
<CotizacionForm
mode="new"
servicios={servicios.map((s) => ({
...s,
entregablesDefault: s.entregablesDefault as string[],
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
}))}
asesores={asesores}
paquetes={paquetes.map((p) => ({
id: p.id,
nombre: p.nombre,
fases: p.fases.map((f) => ({
nombre: f.nombre,
orden: f.orden,
servicios: f.servicios.map((sp) => ({
id: sp.servicio.id,
nombre: sp.servicio.nombre,
fase: sp.servicio.fase,
tipoPago: sp.servicio.tipoPago,
precioBase: sp.servicio.precioBase,
tiempoEntrega: sp.servicio.tiempoEntrega,
entregablesDefault: Array.isArray(sp.servicio.entregablesDefault) ? sp.servicio.entregablesDefault as string[] : [],
})),
})),
}))}
nextSeq={nextSeq}
/>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { Plus, FileText } from "lucide-react";
import { prisma } from "@/lib/db";
import { CotizacionesList } from "./CotizacionesList";
export const dynamic = "force-dynamic";
export default async function CotizacionesPage() {
const cotizaciones = await prisma.cotizacion.findMany({
orderBy: { createdAt: "desc" },
include: { cliente: true, asesor: true, servicios: true },
});
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold">Cotizaciones</h1>
<p className="text-muted text-sm mt-1">
{cotizaciones.length} cotizaciones registradas
</p>
</div>
<Link
href="/cotizaciones/nueva"
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium text-sm"
>
<Plus className="w-4 h-4" />
Nueva Cotizacion
</Link>
</div>
{cotizaciones.length === 0 ? (
<div className="bg-card-bg rounded-xl border border-border p-12 text-center">
<FileText className="w-16 h-16 text-muted mx-auto mb-4" />
<h2 className="text-lg font-semibold mb-2">
No hay cotizaciones
</h2>
<p className="text-muted mb-4">
Comienza creando tu primera cotizacion
</p>
<Link
href="/cotizaciones/nueva"
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark"
>
<Plus className="w-4 h-4" />
Crear Cotizacion
</Link>
</div>
) : (
<CotizacionesList cotizaciones={cotizaciones} />
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import Sidebar from "@/components/layout/Sidebar";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Sidebar />
<main className="flex-1 lg:ml-64">
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
</main>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import Link from "next/link";
import { FileText, Plus, Users, Database } from "lucide-react";
import { prisma } from "@/lib/db";
import { formatCurrency, formatDate } from "@/lib/calculators";
import { EstadoBadge } from "@/components/EstadoBadge";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const [
totalCotizaciones,
cotizacionesRecientes,
totalClientes,
totalServicios,
] = await Promise.all([
prisma.cotizacion.count(),
prisma.cotizacion.findMany({
take: 10,
orderBy: { createdAt: "desc" },
include: { cliente: true, asesor: true, servicios: true },
}),
prisma.cliente.count(),
prisma.servicioCatalogo.count({ where: { activo: true } }),
]);
const stats = [
{
label: "Cotizaciones",
value: totalCotizaciones,
icon: FileText,
color: "text-primary",
bg: "bg-primary-light",
},
{
label: "Clientes",
value: totalClientes,
icon: Users,
color: "text-secondary",
bg: "bg-purple-100",
},
{
label: "Servicios Activos",
value: totalServicios,
icon: Database,
color: "text-accent",
bg: "bg-cyan-100",
},
];
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted text-sm mt-1">
Bienvenido al Cotizador E3
</p>
</div>
<Link
href="/cotizaciones/nueva"
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium text-sm"
>
<Plus className="w-4 h-4" />
Nueva Cotizacion
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{stats.map((stat) => (
<div
key={stat.label}
className="bg-card-bg rounded-xl border border-border p-5"
>
<div className="flex items-center gap-3">
<div className={`${stat.bg} p-2.5 rounded-lg`}>
<stat.icon className={`w-5 h-5 ${stat.color}`} />
</div>
<div>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-muted text-sm">{stat.label}</p>
</div>
</div>
</div>
))}
</div>
<div className="bg-card-bg rounded-xl border border-border">
<div className="px-5 py-4 border-b border-border">
<h2 className="font-semibold text-lg">Cotizaciones Recientes</h2>
</div>
{cotizacionesRecientes.length === 0 ? (
<div className="p-8 text-center">
<FileText className="w-12 h-12 text-muted mx-auto mb-3" />
<p className="text-muted">
No hay cotizaciones. Crea la primera.
</p>
<Link
href="/cotizaciones/nueva"
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark"
>
<Plus className="w-4 h-4" />
Nueva Cotizacion
</Link>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left px-5 py-3 font-medium text-muted">
No.
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Cliente
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Fecha
</th>
<th className="text-left px-5 py-3 font-medium text-muted">
Estado
</th>
<th className="text-right px-5 py-3 font-medium text-muted">
Servicios
</th>
</tr>
</thead>
<tbody>
{cotizacionesRecientes.map((cot) => {
const totalUnico = cot.servicios
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
.reduce((sum, s) => sum + s.precio, 0);
const totalMensual = cot.servicios
.filter(
(s) => s.tipoPago === "mensual" && s.seleccionado
)
.reduce((sum, s) => sum + s.precio, 0);
return (
<tr
key={cot.id}
className="border-b border-border hover:bg-gray-50 transition-colors"
>
<td className="px-5 py-3">
<Link
href={`/cotizaciones/${cot.id}`}
className="text-primary font-medium hover:underline"
>
{cot.numero}
</Link>
</td>
<td className="px-5 py-3">
{cot.cliente.empresa || cot.cliente.nombre}
</td>
<td className="px-5 py-3 text-muted">
{formatDate(cot.fecha)}
</td>
<td className="px-5 py-3">
<EstadoBadge estado={cot.estado} />
</td>
<td className="px-5 py-3 text-right font-medium">
{totalUnico > 0 && (
<span className="block text-xs text-muted">
Unico: {formatCurrency(totalUnico)}
</span>
)}
{totalMensual > 0 && (
<span className="block text-xs text-muted">
Mensual: {formatCurrency(totalMensual)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/db";
import { createSession, setSessionCookie } from "@/lib/auth";
import { loginSchema } from "@/lib/schemas";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.password) {
return NextResponse.json(
{ error: "Credenciales invalidas" },
{ status: 401 }
);
}
const valid = await compare(password, user.password);
if (!valid) {
return NextResponse.json(
{ error: "Credenciales invalidas" },
{ status: 401 }
);
}
const token = await createSession({
userId: user.id,
email: user.email,
name: user.name,
role: user.role,
});
const response = NextResponse.json({
user: { id: user.id, name: user.name, email: user.email, role: user.role },
});
response.cookies.set(setSessionCookie(token));
return response;
} catch {
return NextResponse.json({ error: "Error interno" }, { status: 500 });
}
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { clearSessionCookie } from "@/lib/auth";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(clearSessionCookie());
return response;
}
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
export async function GET() {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "No autenticado" }, { status: 401 });
}
return NextResponse.json({ user: session });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const servicio = await prisma.servicioCatalogo.update({
where: { id },
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
fase: body.fase,
tipoPago: body.tipoPago,
precioBase: body.precioBase,
tiempoEntrega: body.tiempoEntrega,
entregablesDefault: body.entregablesDefault || [],
variante: body.variante || null,
activo: body.activo !== false,
orden: body.orden,
categoriaId: body.categoriaId || null,
},
});
return NextResponse.json(servicio);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inUse = await prisma.servicioCotizado.count({
where: { servicioCatalogoId: id },
});
if (inUse > 0) {
await prisma.servicioCatalogo.update({
where: { id },
data: { activo: false },
});
return NextResponse.json({ ok: true, archived: true });
}
await prisma.servicioCatalogo.delete({ where: { id } });
return NextResponse.json({ ok: true, archived: false });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { servicioCatalogoSchema } from "@/lib/schemas";
export async function GET() {
try {
const servicios = await prisma.servicioCatalogo.findMany({
orderBy: [{ fase: "asc" }, { orden: "asc" }],
});
return NextResponse.json(servicios);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const parsed = servicioCatalogoSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const servicio = await prisma.servicioCatalogo.create({
data: {
nombre: parsed.data.nombre,
descripcion: parsed.data.descripcion,
fase: parsed.data.fase,
tipoPago: parsed.data.tipoPago,
precioBase: parsed.data.precioBase,
tiempoEntrega: parsed.data.tiempoEntrega,
entregablesDefault: parsed.data.entregablesDefault,
categoriaId: parsed.data.categoriaId,
variante: parsed.data.variante,
activo: true,
orden: parsed.data.orden,
},
});
return NextResponse.json(servicio, { status: 201 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const cat = await prisma.categoria.update({
where: { id },
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
color: body.color,
orden: body.orden,
activo: body.activo !== false,
},
});
return NextResponse.json(cat);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const inUse = await prisma.servicioCatalogo.count({ where: { categoriaId: id } });
if (inUse > 0) {
return NextResponse.json(
{ error: `Tiene ${inUse} servicios asociados. Desactiva la categoria en su lugar.` },
{ status: 409 }
);
}
await prisma.categoria.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const cats = await prisma.categoria.findMany({ orderBy: { orden: "asc" } });
return NextResponse.json(cats);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const cat = await prisma.categoria.create({
data: {
nombre: body.nombre,
descripcion: body.descripcion || null,
color: body.color || "#6b7280",
orden: body.orden ?? 0,
},
});
return NextResponse.json(cat, { status: 201 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const configs = await prisma.configuracion.findMany();
const map: Record<string, string> = {};
for (const c of configs) {
map[c.clave] = c.valor;
}
return NextResponse.json(map);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = (await request.json()) as Record<string, string>;
const allowedKeys = [
"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",
];
const ops = Object.entries(body)
.filter(([clave]) => allowedKeys.includes(clave))
.map(([clave, valor]) =>
prisma.configuracion.upsert({
where: { clave },
update: { valor },
create: { clave, valor },
})
);
await prisma.$transaction(ops);
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More