Restaurar funcionalidades desde stash + setup de despliegue Coolify

- Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop
- Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED)
- docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_*
- Endpoint público /api/health (verifica BD) para healthchecks
- seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas)
- .env.example, .dockerignore, uvicorn con --proxy-headers
- Limpieza: .xlsx y __pycache__ fuera del repo

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-06-10 02:21:18 -06:00
co-authored by Claude Fable 5
parent c3f4449296
commit 69c74faabb
43 changed files with 3536 additions and 1139 deletions
+26 -10
View File
@@ -46,6 +46,12 @@
- **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly). - **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. - **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.
## Auth
- **JWT sessions** (`src/lib/auth.ts`) signed with `jose` (HS256, 7-day expiry), stored in the `cotizador-session` httpOnly cookie. `JWT_SECRET` env var is **required** (throws at startup if missing).
- **`src/middleware.ts`** gates everything except `PUBLIC_PATHS` (`/login`, `/api/auth/login`, `/api/auth/logout`, `/api/health`). Unauthenticated API calls → 401 JSON; pages → redirect to `/login`. On success it injects `x-user-id` / `x-user-role` request headers for downstream handlers.
- Passwords hashed with `bcryptjs`. Roles: `asesor` (default), and others stored as free-form strings.
## Deployment (Coolify) ## Deployment (Coolify)
- Production stack: `docker-compose.coolify.yml` (postgres + web + api). In Coolify set **Docker Compose Location** to `/docker-compose.coolify.yml`. Local dev keeps using `docker-compose.yml` + `start.bat`. - Production stack: `docker-compose.coolify.yml` (postgres + web + api). In Coolify set **Docker Compose Location** to `/docker-compose.coolify.yml`. Local dev keeps using `docker-compose.yml` + `start.bat`.
@@ -59,21 +65,31 @@
``` ```
src/ src/
app/ # Next.js App Router pages + API routes app/
api/ # Route handlers (REST endpoints) (app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar)
cotizaciones/[id]/editar/ # Edit mode (reuses CotizacionEditor via EditorLoader) api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones, configuracion, paquetes, export, import
components/ # Shared client components (ExportButtons, Sidebar) login/ # Public login page
lib/ # Utilities: db.ts, store.ts, calculators.ts, pdf-generator.ts components/ # CotizacionForm, ExportButtons, EstadoBadge, layout/Sidebar
lib/ # auth, db, store, calculators, pdf-generator, schemas, config-helpers
generated/prisma/ # Prisma client output (gitignored) generated/prisma/ # Prisma client output (gitignored)
prisma/ prisma/
schema.prisma # 8 models schema.prisma # 13 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete,
seed.ts # All catalog data (23 services, bonos, planes, config) # ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion,
migrations/ # Prisma migration files # Configuracion, Bono, FinanciamientoPlan)
seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts
migrations/ # 3 migrations
api/ # Standalone Python FastAPI + MCP server (see below)
``` ```
- **Zustand store** (`src/lib/store.ts`) manages cotización editor state (draft form). Used by both new and edit pages. - **Zustand store** (`src/lib/store.ts`) holds the cotización draft. Used by both the new (`cotizaciones/nueva`) and edit (`cotizaciones/[id]/editar`) pages, both of which render `CotizacionForm.tsx`.
- **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body). - **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`.
## Python API (`api/`) — Optional Second Backend
- **FastAPI app** (`api/main.py`) exposing the same domain as REST, **plus an MCP server at `/mcp`** for AI agents (n8n, Claude, ChatGPT). Tools/resources defined in `api/app/mcp/`.
- Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions).
- Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`.
- It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma).
## Domain ## Domain
+47 -19
View File
@@ -1,33 +1,60 @@
# Cotizador E3 # Cotizador E3
Sistema de cotizaciones de Consultoría E3. Dos backends sobre la misma base de datos PostgreSQL: Sistema de generación de cotizaciones para Consultoría E3 (marketing digital, Querétaro MX).
Servicios organizados en 4 fases, dos tipos de pago (único / mensual), planes CRM Bucéfalo
y financiamiento opcional. Exporta a PDF y Excel.
- **Web (Next.js 16)** — `src/`, puerto 3000. UI completa: cotizaciones, clientes, catálogo, configuración, export PDF/Excel. Prisma es dueño del esquema y las migraciones. ## Stack
- **API (FastAPI + MCP)** — `api/`, puerto 8000. REST para integraciones (n8n, agentes de IA) con servidor MCP en `/mcp`. Referencia completa en `api/COTIZADOR_API_SKILL.md`.
## Desarrollo local (Windows) - **Frontend / app web:** Next.js 16 (App Router) + React 19 + Tailwind CSS v4 + Zustand
- **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`)
- **Base de datos:** PostgreSQL 16 (vía Docker)
- **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs`
- **API alterna:** servicio Python FastAPI + servidor MCP en [`api/`](api/) (para n8n / agentes de IA)
Requisitos: Node 20+, Docker Desktop. ## Requisitos
```bash - Node.js 20+
# 1. Copia las variables de entorno - Docker (para PostgreSQL)
copy .env.example .env # y define JWT_SECRET - Un archivo `.env` en la raíz — copia [.env.example](.env.example) y define al menos `JWT_SECRET`
# 2. Levanta PostgreSQL + API Python y el dev server de Next.js ## Arranque rápido (Windows)
start.bat
```bat
start.bat :: levanta PostgreSQL en Docker, aplica migraciones y arranca Next.js
stop.bat :: detiene todo
``` ```
`start.bat` levanta el compose local (`docker-compose.yml`), aplica migraciones y arranca `npm run dev`. Para detener todo: `stop.bat`. ## Arranque manual
Comandos útiles: ```bash
docker compose up -d postgres # base de datos
npx prisma migrate deploy # aplica migraciones
npx tsx prisma/seed.ts # carga catálogo (idempotente)
npm run dev # http://localhost:3000
```
## Comandos
| Comando | Propósito | | Comando | Propósito |
|---------|-----------| |---------|-----------|
| `npm run dev` | Dev server Next.js (puerto 3000) | | `npm run dev` | Servidor de desarrollo (puerto 3000) |
| `npm run build` | Build de producción | | `npm run build` | Build de producción (incluye chequeo de tipos) |
| `npx prisma migrate dev` | Crear/aplicar migración | | `npm run lint` | ESLint |
| `npx tsx prisma/seed.ts` | Seed (idempotente; usuarios vía `SEED_*`) | | `npm run db:migrate` | `prisma migrate dev` |
| `npx prisma studio` | GUI de la base de datos | | `npm run db:seed` | Carga de datos semilla |
| `npm run db:studio` | Prisma Studio |
| `npm run db:generate` | Regenera el cliente Prisma |
## API Python (opcional)
```bash
cd api
pip install -r requirements.txt
uvicorn main:app --reload --port 8000 # Swagger en /docs, MCP en /mcp
```
Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md).
## Despliegue en Coolify ## Despliegue en Coolify
@@ -65,6 +92,7 @@ El stack de producción está en [docker-compose.coolify.yml](docker-compose.coo
- El servidor MCP queda en `https://<dominio-api>/mcp` (auth por `X-API-Key`). - El servidor MCP queda en `https://<dominio-api>/mcp` (auth por `X-API-Key`).
- La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad. - La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad.
## Arquitectura ## Documentación para agentes
Ver [AGENTS.md](AGENTS.md) para convenciones, gotchas de Prisma 7 / PDFKit / Next 16 y el modelo de datos. Las convenciones del proyecto, gotchas de Prisma 7 / PDFKit / Next.js 16 y el modelo de datos
están en [`AGENTS.md`](AGENTS.md) (importado por `CLAUDE.md`).
+2 -2
View File
@@ -15,8 +15,8 @@ async def init_db() -> None:
user=settings.DB_USER, user=settings.DB_USER,
password=settings.DB_PASSWORD, password=settings.DB_PASSWORD,
database=settings.DB_NAME, database=settings.DB_NAME,
min_size=2, min_size=5,
max_size=10, max_size=20,
) )
+20 -13
View File
@@ -168,6 +168,8 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
moneda = args.get("moneda", "MXN") moneda = args.get("moneda", "MXN")
proyecto = args.get("proyecto", "MKT Digital") proyecto = args.get("proyecto", "MKT Digital")
esquema = args.get("esquema_pago", "Pago Unico/Mensual") esquema = args.get("esquema_pago", "Pago Unico/Mensual")
es_doble = bool(args.get("es_doble", False))
opciones_metadata = args.get("opciones_metadata") if es_doble else None
cliente = await conn.fetchrow( cliente = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2', 'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
@@ -194,10 +196,13 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
async with conn.transaction(): async with conn.transaction():
cot = await conn.fetchrow( cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", """INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, '', $7, $8, NOW(), NOW()) VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, $7, $8, '', $9, $10, NOW(), NOW())
RETURNING id, numero""", RETURNING id, numero""",
numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent", numero, now, vigencia, moneda, proyecto, esquema,
es_doble,
json.dumps(opciones_metadata) if opciones_metadata else None,
cliente_id, "agent",
) )
cot_id = cot["id"] cot_id = cot["id"]
@@ -218,12 +223,14 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
entregables = [] entregables = []
opcion = (srv.get("opcion") or "ambas") if es_doble else None
await conn.fetchrow( await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", """INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, seleccionado, "createdAt", "updatedAt") precio, "tiempoEntrega", entregables, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())""", VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())""",
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"], cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), opcion,
) )
if plan_bucefalo: if plan_bucefalo:
@@ -363,13 +370,13 @@ async def _duplicar_cotizacion(conn, args: dict) -> dict:
async with conn.transaction(): async with conn.transaction():
new_cot = await conn.fetchrow( new_cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", """INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, NOW(), NOW()) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
RETURNING id, numero""", RETURNING id, numero""",
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"], new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
original["proyecto"], original["esquemaPago"], original["incluirBonos"], original["proyecto"], original["esquemaPago"], original["incluirBonos"],
original["incluirFinanciamiento"], original["observaciones"], original["incluirFinanciamiento"], original["esDoble"], original["opcionesMetadata"],
original["clienteId"], original["asesorId"], original["observaciones"], original["clienteId"], original["asesorId"],
) )
new_id = new_cot["id"] new_id = new_cot["id"]
@@ -379,10 +386,10 @@ async def _duplicar_cotizacion(conn, args: dict) -> dict:
for s in servicios: for s in servicios:
await conn.fetchrow( await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", """INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt") precio, "tiempoEntrega", entregables, notas, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())""", VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())""",
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"], new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"], s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["opcion"], s["seleccionado"],
) )
plan = await conn.fetchrow( plan = await conn.fetchrow(
+17 -1
View File
@@ -45,7 +45,10 @@ TOOLS = [
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, " "Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
"y configuración de moneda y esquema de pago. " "y configuración de moneda y esquema de pago. "
"La cotización se crea en estado 'borrador'. " "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." "Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes. "
"Soporta DOBLE PROPUESTA: con es_doble=true se presentan dos opciones comparables; cada servicio "
"se asigna a la opción '1', '2' o 'ambas' (compartido), y opciones_metadata define el título, "
"descripción y exclusiones de cada opción."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -67,11 +70,24 @@ TOOLS = [
"properties": { "properties": {
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"}, "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)"}, "precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
"opcion": {"type": "string", "enum": ["1", "2", "ambas"], "description": "Solo en doble propuesta: opción a la que pertenece el servicio ('ambas' = compartido). Default 'ambas'."},
}, },
"required": ["servicio_id"], "required": ["servicio_id"],
}, },
"description": "Lista de servicios a incluir en la cotización", "description": "Lista de servicios a incluir en la cotización",
}, },
"es_doble": {
"type": "boolean",
"description": "Si es true, la cotización presenta dos opciones comparables (doble propuesta).",
},
"opciones_metadata": {
"type": "object",
"description": "Solo en doble propuesta. Metadatos por opción, p.ej. {\"1\": {\"titulo\": \"...\", \"descripcion\": \"...\", \"noIncluye\": \"...\"}, \"2\": {...}}.",
"properties": {
"1": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
"2": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
},
},
"plan_bucefalo": { "plan_bucefalo": {
"type": "string", "type": "string",
"enum": ["basico", "estandar", "premium", "empresarial"], "enum": ["basico", "estandar", "premium", "empresarial"],
+2
View File
@@ -43,6 +43,7 @@ class ServicioCatalogoBase(BaseModel):
entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables") entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables")
categoriaId: str = Field(..., min_length=1, description="ID de la categoría") categoriaId: str = Field(..., min_length=1, description="ID de la categoría")
variante: str | None = None variante: str | None = None
nivel: str | None = Field(None, description="Nivel/tier: Emprendedor, PYME, Empresario, Corporativo")
orden: int = Field(0, ge=0) orden: int = Field(0, ge=0)
@@ -60,6 +61,7 @@ class ServicioCatalogoUpdate(BaseModel):
entregablesDefault: list[str] | None = None entregablesDefault: list[str] | None = None
categoriaId: str | None = None categoriaId: str | None = None
variante: str | None = None variante: str | None = None
nivel: str | None = None
activo: bool | None = None activo: bool | None = None
orden: int | None = Field(None, ge=0) orden: int | None = Field(None, ge=0)
+22 -1
View File
@@ -7,13 +7,28 @@ from pydantic import BaseModel, Field
class ServicioCotizadoInput(BaseModel): class ServicioCotizadoInput(BaseModel):
catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo") catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo o 'custom-...' para partidas personalizadas")
nombre: str = Field(..., min_length=1, description="Nombre del servicio") nombre: str = Field(..., min_length=1, description="Nombre del servicio")
fase: int = Field(..., ge=0, le=3) fase: int = Field(..., ge=0, le=3)
tipoPago: str = Field(..., pattern="^(unico|mensual)$") tipoPago: str = Field(..., pattern="^(unico|mensual)$")
precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)") precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)")
tiempoEntrega: str tiempoEntrega: str
entregables: list[str] = Field(default_factory=list) entregables: list[str] = Field(default_factory=list)
# Partidas por tiempo (sin catálogo). modeloCobro: "fijo" | "horas" | "retainer".
esPersonalizado: bool = False
horas: float | None = Field(None, ge=0)
tarifaHora: float | None = Field(None, ge=0)
modeloCobro: str | None = Field(None, pattern="^(fijo|horas|retainer)$")
montoMinimo: float | None = Field(None, ge=0)
horasIncluidas: float | None = Field(None, ge=0)
# Doble propuesta: "1" | "2" | "ambas". None en cotizaciones normales.
opcion: str | None = Field(None, pattern="^(1|2|ambas)$")
class MetaOpcionInput(BaseModel):
titulo: str | None = None
descripcion: str | None = None
noIncluye: str | None = None
class PlanBucefaloInput(BaseModel): class PlanBucefaloInput(BaseModel):
@@ -38,6 +53,8 @@ class CotizacionCreate(BaseModel):
esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$") esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
incluirBonos: bool = False incluirBonos: bool = False
incluirFinanciamiento: bool = False incluirFinanciamiento: bool = False
esDoble: bool = False
opciones: dict[str, MetaOpcionInput] | None = None
observaciones: str = "" observaciones: str = ""
asesorId: str = Field(..., min_length=1) asesorId: str = Field(..., min_length=1)
cliente: ClienteInput cliente: ClienteInput
@@ -92,6 +109,8 @@ class CotizacionUpdate(BaseModel):
esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$") esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
incluirBonos: bool | None = None incluirBonos: bool | None = None
incluirFinanciamiento: bool | None = None incluirFinanciamiento: bool | None = None
esDoble: bool | None = None
opciones: dict[str, MetaOpcionInput] | None = None
observaciones: str | None = None observaciones: str | None = None
estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$") estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$")
cliente: ClienteInput | None = None cliente: ClienteInput | None = None
@@ -120,6 +139,8 @@ class CotizacionResponse(BaseModel):
estado: str estado: str
incluirBonos: bool incluirBonos: bool
incluirFinanciamiento: bool incluirFinanciamiento: bool
esDoble: bool = False
opcionesMetadata: dict[str, Any] | None = None
observaciones: str | None observaciones: str | None
clienteId: str clienteId: str
asesorId: str asesorId: str
+16
View File
@@ -10,6 +10,20 @@ class ServicioDraft(BaseModel):
precio: float precio: float
tiempoEntrega: str tiempoEntrega: str
entregables: list[str] = Field(default_factory=list) entregables: list[str] = Field(default_factory=list)
esPersonalizado: bool = False
horas: float | None = None
tarifaHora: float | None = None
modeloCobro: str | None = None
montoMinimo: float | None = None
horasIncluidas: float | None = None
# Doble propuesta: "1" | "2" | "ambas".
opcion: str | None = None
class MetaOpcionDraft(BaseModel):
titulo: str | None = None
descripcion: str | None = None
noIncluye: str | None = None
class ExportDraft(BaseModel): class ExportDraft(BaseModel):
@@ -25,6 +39,8 @@ class ExportDraft(BaseModel):
servicios: list[ServicioDraft] = Field(default_factory=list) servicios: list[ServicioDraft] = Field(default_factory=list)
planBucefaloNivel: str | None = None planBucefaloNivel: str | None = None
observaciones: str = "" observaciones: str = ""
esDoble: bool = False
opciones: dict[str, MetaOpcionDraft] | None = None
class FinanciamientoRequest(BaseModel): class FinanciamientoRequest(BaseModel):
+13 -10
View File
@@ -47,6 +47,7 @@ def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse:
entregablesDefault=entregables, entregablesDefault=entregables,
categoriaId=row["categoriaId"], categoriaId=row["categoriaId"],
variante=row["variante"], variante=row["variante"],
nivel=row["nivel"],
activo=row["activo"], activo=row["activo"],
orden=row["orden"], orden=row["orden"],
createdAt=row["createdAt"], createdAt=row["createdAt"],
@@ -99,16 +100,16 @@ async def list_catalogo(
*params, *params,
) )
result: list[ServicioCatalogoResponse] = [] # Una sola query para todas las categorías referenciadas (evita N+1).
for r in rows: cat_ids = list({r["categoriaId"] for r in rows if r["categoriaId"]})
cat_row = None cat_map: dict = {}
if r["categoriaId"]: if cat_ids:
cat_row = await db.fetchrow( cat_rows = await db.fetch(
'SELECT * FROM "Categoria" WHERE id = $1', r["categoriaId"] 'SELECT * FROM "Categoria" WHERE id = ANY($1::text[])', cat_ids
) )
result.append(_row_to_response(r, cat_row)) cat_map = {c["id"]: c for c in cat_rows}
return result return [_row_to_response(r, cat_map.get(r["categoriaId"])) for r in rows]
@router.post( @router.post(
@@ -134,8 +135,8 @@ async def create_catalogo(
row = await db.fetchrow( row = await db.fetchrow(
'INSERT INTO "ServicioCatalogo" ' 'INSERT INTO "ServicioCatalogo" '
'(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", ' '(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", '
'"entregablesDefault", "categoriaId", variante, activo, orden) ' '"entregablesDefault", "categoriaId", variante, nivel, activo, orden) '
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10) RETURNING *", "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, $11) RETURNING *",
body.nombre, body.nombre,
body.descripcion, body.descripcion,
body.fase, body.fase,
@@ -145,6 +146,7 @@ async def create_catalogo(
entregables_json, entregables_json,
body.categoriaId, body.categoriaId,
body.variante, body.variante,
body.nivel,
body.orden, body.orden,
) )
@@ -218,6 +220,7 @@ async def update_catalogo(
"entregablesDefault": '"entregablesDefault"', "entregablesDefault": '"entregablesDefault"',
"categoriaId": '"categoriaId"', "categoriaId": '"categoriaId"',
"variante": "variante", "variante": "variante",
"nivel": "nivel",
"activo": "activo", "activo": "activo",
"orden": "orden", "orden": "orden",
} }
+148 -69
View File
@@ -26,8 +26,66 @@ def _cuid() -> str:
return uuid.uuid4().hex[:25] return uuid.uuid4().hex[:25]
async def _insert_servicio_cotizado(conn, cotizacion_id: str, svc, bucefalo_servicio_id, now, es_doble: bool = False) -> None:
"""Inserta un ServicioCotizado desde un ServicioCotizadoInput, manejando
partidas personalizadas (por horas / retainer) en paridad con el backend Next.js."""
es_personalizado = bool(getattr(svc, "esPersonalizado", False)) or svc.catalogoId.startswith("custom-")
if es_personalizado:
catalogo_id = None
elif svc.catalogoId.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
else:
catalogo_id = svc.catalogoId
modelo_cobro = svc.modeloCobro or ("horas" if es_personalizado else "fijo")
# Doble propuesta: por defecto "ambas"; None en cotizaciones normales.
opcion = (svc.opcion or "ambas") if es_doble else None
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$18)
""",
_cuid(),
cotizacion_id,
catalogo_id,
svc.nombre if es_personalizado else None,
es_personalizado,
svc.horas if es_personalizado else None,
svc.tarifaHora if es_personalizado else None,
modelo_cobro,
svc.montoMinimo if es_personalizado else None,
svc.horasIncluidas if es_personalizado else None,
opcion,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
def _parse_json(val):
"""asyncpg devuelve columnas jsonb como str; las parsea a dict/list."""
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return None
return val
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict: def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
d = dict(cot) d = dict(cot)
if "opcionesMetadata" in d:
d["opcionesMetadata"] = _parse_json(d["opcionesMetadata"])
d["cliente"] = dict(cliente) if cliente else None d["cliente"] = dict(cliente) if cliente else None
d["asesor"] = dict(asesor) if asesor else None d["asesor"] = dict(asesor) if asesor else None
d["servicios"] = [dict(s) for s in servicios] if servicios else [] d["servicios"] = [dict(s) for s in servicios] if servicios else []
@@ -35,6 +93,18 @@ def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=
return d return d
# LEFT JOIN: los servicios personalizados no tienen servicioCatalogoId y un JOIN
# interno los dejaría fuera de la respuesta. COALESCE conserva el nombre custom.
_SERVICIOS_SQL = """
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
COALESCE(s.nombre, sc.nombre) AS nombre, s.descripcion, s."categoriaId"
FROM "ServicioCotizado" sc
LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = {}
ORDER BY sc.fase, sc.opcion, sc.id
"""
async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None: async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot: if not cot:
@@ -42,22 +112,46 @@ async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cliente = await conn.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cot["clienteId"]) 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"]) asesor = await conn.fetchrow('SELECT id, email, name, role FROM "User" WHERE id = $1', cot["asesorId"])
servicios = await conn.fetch( servicios = await conn.fetch(_SERVICIOS_SQL.format("$1"), cot_id)
"""
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) plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan) return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
async def _fetch_cotizaciones_full(conn, cot_rows) -> list[dict]:
"""Versión batch de _fetch_cotizacion_full: 4 queries en total para toda la
página en lugar de 4 por cotización (evita N+1 en el listado)."""
if not cot_rows:
return []
cot_ids = [r["id"] for r in cot_rows]
cliente_ids = list({r["clienteId"] for r in cot_rows})
asesor_ids = list({r["asesorId"] for r in cot_rows})
clientes = await conn.fetch('SELECT * FROM "Cliente" WHERE id = ANY($1::text[])', cliente_ids)
asesores = await conn.fetch('SELECT id, email, name, role FROM "User" WHERE id = ANY($1::text[])', asesor_ids)
servicios = await conn.fetch(_SERVICIOS_SQL.format("ANY($1::text[])"), cot_ids)
planes = await conn.fetch('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = ANY($1::text[])', cot_ids)
clientes_map = {c["id"]: c for c in clientes}
asesores_map = {a["id"]: a for a in asesores}
planes_map = {p["cotizacionId"]: p for p in planes}
servicios_map: dict[str, list] = {}
for s in servicios:
servicios_map.setdefault(s["cotizacionId"], []).append(s)
return [
_build_cotizacion_dict(
cot,
clientes_map.get(cot["clienteId"]),
asesores_map.get(cot["asesorId"]),
servicios_map.get(cot["id"], []),
planes_map.get(cot["id"]),
)
for cot in cot_rows
]
async def _resolve_bucefalo_servicio(conn) -> str | None: async def _resolve_bucefalo_servicio(conn) -> str | None:
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
@@ -168,11 +262,7 @@ async def list_cotizaciones(
pagination.offset, pagination.offset,
) )
cotizaciones = [] cotizaciones = await _fetch_cotizaciones_full(pool, rows)
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 pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0
@@ -200,13 +290,16 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
cot_id = _cuid() cot_id = _cuid()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
opciones_json = None
if body.esDoble and body.opciones:
opciones_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
await conn.execute( await conn.execute(
""" """
INSERT INTO "Cotizacion" INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
"clienteId", "asesorId", "createdAt", "updatedAt") observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
""", """,
cot_id, cot_id,
body.numero, body.numero,
@@ -218,6 +311,8 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
body.esquemaPago, body.esquemaPago,
body.incluirBonos, body.incluirBonos,
body.incluirFinanciamiento, body.incluirFinanciamiento,
body.esDoble,
opciones_json,
body.observaciones or None, body.observaciones or None,
cliente_id, cliente_id,
body.asesorId, body.asesorId,
@@ -227,28 +322,7 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn) bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
for svc in body.servicios: for svc in body.servicios:
catalogo_id = svc.catalogoId await _insert_servicio_cotizado(conn, cot_id, svc, bucefalo_servicio_id, now, body.esDoble)
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: if body.planBucefalo:
await conn.execute( await conn.execute(
@@ -265,7 +339,9 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
) )
row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
return dict(row) result = dict(row)
result["opcionesMetadata"] = _parse_json(result.get("opcionesMetadata"))
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -325,9 +401,20 @@ async def update_cotizacion(
_add("esquemaPago", '"esquemaPago"', body.esquemaPago) _add("esquemaPago", '"esquemaPago"', body.esquemaPago)
_add("incluirBonos", '"incluirBonos"', body.incluirBonos) _add("incluirBonos", '"incluirBonos"', body.incluirBonos)
_add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento) _add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento)
_add("esDoble", '"esDoble"', body.esDoble)
_add("observaciones", "observaciones", body.observaciones) _add("observaciones", "observaciones", body.observaciones)
_add("estado", "estado", body.estado) _add("estado", "estado", body.estado)
es_doble_final = body.esDoble if body.esDoble is not None else existing["esDoble"]
if body.esDoble is not None:
# opcionesMetadata se sincroniza con esDoble: dict (json) si doble, NULL si no.
meta_json = None
if es_doble_final and body.opciones:
meta_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
updates.append(f'"opcionesMetadata" = ${idx}')
params.append(meta_json)
idx += 1
if cliente_id != existing["clienteId"]: if cliente_id != existing["clienteId"]:
updates.append(f'"clienteId" = ${idx}') updates.append(f'"clienteId" = ${idx}')
params.append(cliente_id) params.append(cliente_id)
@@ -348,27 +435,7 @@ async def update_cotizacion(
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn) bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for svc in body.servicios: for svc in body.servicios:
catalogo_id = svc.catalogoId await _insert_servicio_cotizado(conn, cotizacion_id, svc, bucefalo_servicio_id, now, es_doble_final)
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: if body.planBucefalo is not None:
existing_plan = await conn.fetchrow( existing_plan = await conn.fetchrow(
@@ -505,9 +572,9 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
""" """
INSERT INTO "Cotizacion" INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
"clienteId", "asesorId", "createdAt", "updatedAt") observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
""", """,
new_id, new_id,
new_numero, new_numero,
@@ -519,6 +586,8 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
original["esquemaPago"], original["esquemaPago"],
original["incluirBonos"], original["incluirBonos"],
original["incluirFinanciamiento"], original["incluirFinanciamiento"],
original["esDoble"],
original["opcionesMetadata"],
original["observaciones"], original["observaciones"],
original["clienteId"], original["clienteId"],
original["asesorId"], original["asesorId"],
@@ -529,13 +598,23 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
await conn.execute( await conn.execute(
""" """
INSERT INTO "ServicioCotizado" INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio, (id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt") horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11) opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)
""", """,
_cuid(), _cuid(),
new_id, new_id,
svc["servicioCatalogoId"], svc["servicioCatalogoId"],
svc["nombre"],
svc["esPersonalizado"],
svc["horas"],
svc["tarifaHora"],
svc["modeloCobro"],
svc["montoMinimo"],
svc["horasIncluidas"],
svc["opcion"],
svc["fase"], svc["fase"],
svc["tipoPago"], svc["tipoPago"],
svc["precio"], svc["precio"],
+35 -4
View File
@@ -69,9 +69,20 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) ->
"precio": s.precio, "precio": s.precio,
"tiempoEntrega": s.tiempoEntrega, "tiempoEntrega": s.tiempoEntrega,
"entregables": s.entregables, "entregables": s.entregables,
"esPersonalizado": s.esPersonalizado,
"horas": s.horas,
"tarifaHora": s.tarifaHora,
"modeloCobro": s.modeloCobro,
"montoMinimo": s.montoMinimo,
"horasIncluidas": s.horasIncluidas,
"opcion": s.opcion,
} }
for s in draft.servicios for s in draft.servicios
], ],
"esDoble": draft.esDoble,
"opcionesMetadata": (
{k: v.model_dump() for k, v in draft.opciones.items()} if draft.esDoble and draft.opciones else None
),
"planBucefaloNivel": draft.planBucefaloNivel, "planBucefaloNivel": draft.planBucefaloNivel,
"planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0, "planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0,
"incluirBonos": draft.incluirBonos, "incluirBonos": draft.incluirBonos,
@@ -81,6 +92,7 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) ->
"logoMime": logo_mime, "logoMime": logo_mime,
"configBancaria": branding, "configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"), "razonSocial": branding.get("razon_social", "Cotizador E3"),
"domicilioFiscal": branding.get("domicilio_fiscal"),
} }
@@ -99,11 +111,13 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
servicios = await db.fetch( servicios = await db.fetch(
"""SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables, """SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables,
s.nombre sc."esPersonalizado", sc.horas, sc."tarifaHora", sc."modeloCobro",
sc."montoMinimo", sc."horasIncluidas", sc.opcion,
COALESCE(s.nombre, sc.nombre) AS nombre
FROM "ServicioCotizado" sc FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId" LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true
ORDER BY sc.fase, sc.id""", ORDER BY sc.fase, sc.opcion, sc.id""",
cotizacion_id, cotizacion_id,
) )
@@ -133,14 +147,28 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
ent = [] ent = []
servicios_list.append({ servicios_list.append({
"nombre": s["nombre"], "nombre": s["nombre"] or "Servicio",
"fase": s["fase"], "fase": s["fase"],
"tipoPago": s["tipoPago"], "tipoPago": s["tipoPago"],
"precio": float(s["precio"]), "precio": float(s["precio"]),
"tiempoEntrega": s["tiempoEntrega"], "tiempoEntrega": s["tiempoEntrega"],
"entregables": ent or [], "entregables": ent or [],
"esPersonalizado": s["esPersonalizado"],
"horas": float(s["horas"]) if s["horas"] is not None else None,
"tarifaHora": float(s["tarifaHora"]) if s["tarifaHora"] is not None else None,
"modeloCobro": s["modeloCobro"],
"montoMinimo": float(s["montoMinimo"]) if s["montoMinimo"] is not None else None,
"horasIncluidas": float(s["horasIncluidas"]) if s["horasIncluidas"] is not None else None,
"opcion": s["opcion"],
}) })
opciones_meta = cot["opcionesMetadata"]
if isinstance(opciones_meta, str):
try:
opciones_meta = json.loads(opciones_meta)
except (json.JSONDecodeError, TypeError):
opciones_meta = None
plan_nivel = plan["nivel"] if plan else None plan_nivel = plan["nivel"] if plan else None
return { return {
@@ -155,6 +183,8 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
"proyecto": cot["proyecto"], "proyecto": cot["proyecto"],
"esquemaPago": cot["esquemaPago"], "esquemaPago": cot["esquemaPago"],
"servicios": servicios_list, "servicios": servicios_list,
"esDoble": cot["esDoble"],
"opcionesMetadata": opciones_meta,
"planBucefaloNivel": plan_nivel, "planBucefaloNivel": plan_nivel,
"planBucefaloPrecio": float(plan["precio"]) if plan else 0, "planBucefaloPrecio": float(plan["precio"]) if plan else 0,
"incluirBonos": cot["incluirBonos"], "incluirBonos": cot["incluirBonos"],
@@ -164,6 +194,7 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
"logoMime": logo_mime, "logoMime": logo_mime,
"configBancaria": branding, "configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"), "razonSocial": branding.get("razon_social", "Cotizador E3"),
"domicilioFiscal": branding.get("domicilio_fiscal"),
} }
+47 -35
View File
@@ -19,39 +19,43 @@ from app.models.common import ErrorResponse, OkResponse
router = APIRouter(prefix="/paquetes", tags=["Paquetes"]) router = APIRouter(prefix="/paquetes", tags=["Paquetes"])
async def _build_paquete_response(conn, paquete_row: dict) -> dict: async def _build_paquetes_responses(conn, paquete_rows: list[dict]) -> list[dict]:
"""Build a PaqueteResponse dict with nested fases and servicios.""" """Build PaqueteResponse dicts for several paquetes with 2 queries in total
paquete_id = paquete_row["id"] (instead of 1 + N fases per paquete)."""
if not paquete_rows:
return []
paquete_ids = [p["id"] for p in paquete_rows]
fases_rows = await conn.fetch( fases_rows = await conn.fetch(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"' 'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC', ' FROM "FasePaquete" WHERE "paqueteId" = ANY($1::text[]) ORDER BY orden ASC',
paquete_id, paquete_ids,
) )
fases: list[dict] = [] fase_ids = [f["id"] for f in fases_rows]
for fase in fases_rows: servicios_rows = []
if fase_ids:
servicios_rows = await conn.fetch( servicios_rows = await conn.fetch(
'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",' 'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",'
' sp."createdAt", sp."updatedAt",' ' sp."createdAt", sp."updatedAt",'
' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,' ' 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.fase AS "sc_fase", sc."tipoPago" AS "sc_tipoPago",'
' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,' ' sc."precioBase" AS "sc_precioBase", sc."tiempoEntrega" AS "sc_tiempoEntrega",'
' sc."entregablesDefault" AS sc_entregablesDefault,' ' sc."entregablesDefault" AS "sc_entregablesDefault",'
' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,' ' sc."categoriaId" AS "sc_categoriaId", sc.variante AS "sc_variante",'
' sc.activo AS sc_activo, sc.orden AS sc_orden,' ' sc.activo AS "sc_activo", sc.orden AS "sc_orden",'
' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,' ' 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.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.color AS "cat_color", cat.activo AS "cat_activo", cat.orden AS "cat_orden",'
' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt' ' cat."createdAt" AS "cat_createdAt", cat."updatedAt" AS "cat_updatedAt"'
' FROM "ServicioPaquete" sp' ' FROM "ServicioPaquete" sp'
' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"' ' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"'
' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"' ' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"'
' WHERE sp."fasePaqueteId" = $1', ' WHERE sp."fasePaqueteId" = ANY($1::text[])',
fase["id"], fase_ids,
) )
servicios: list[dict] = [] servicios_por_fase: dict[str, list[dict]] = {}
for s in servicios_rows: for s in servicios_rows:
cat = None cat = None
if s["cat_id"]: if s["cat_id"]:
@@ -65,7 +69,7 @@ async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"createdAt": s["cat_createdAt"], "createdAt": s["cat_createdAt"],
"updatedAt": s["cat_updatedAt"], "updatedAt": s["cat_updatedAt"],
} }
servicios.append({ servicios_por_fase.setdefault(s["fasePaqueteId"], []).append({
"id": s["id"], "id": s["id"],
"servicioCatalogoId": s["servicioCatalogoId"], "servicioCatalogoId": s["servicioCatalogoId"],
"fasePaqueteId": s["fasePaqueteId"], "fasePaqueteId": s["fasePaqueteId"],
@@ -90,25 +94,36 @@ async def _build_paquete_response(conn, paquete_row: dict) -> dict:
}, },
}) })
fases.append({ fases_por_paquete: dict[str, list[dict]] = {}
for fase in fases_rows:
fases_por_paquete.setdefault(fase["paqueteId"], []).append({
"id": fase["id"], "id": fase["id"],
"paqueteId": fase["paqueteId"], "paqueteId": fase["paqueteId"],
"nombre": fase["nombre"], "nombre": fase["nombre"],
"orden": fase["orden"], "orden": fase["orden"],
"createdAt": fase["createdAt"], "createdAt": fase["createdAt"],
"updatedAt": fase["updatedAt"], "updatedAt": fase["updatedAt"],
"servicios": servicios, "servicios": servicios_por_fase.get(fase["id"], []),
}) })
return { return [
"id": paquete_row["id"], {
"nombre": paquete_row["nombre"], "id": p["id"],
"descripcion": paquete_row["descripcion"], "nombre": p["nombre"],
"activo": paquete_row["activo"], "descripcion": p["descripcion"],
"createdAt": paquete_row["createdAt"], "activo": p["activo"],
"updatedAt": paquete_row["updatedAt"], "createdAt": p["createdAt"],
"fases": fases, "updatedAt": p["updatedAt"],
"fases": fases_por_paquete.get(p["id"], []),
} }
for p in paquete_rows
]
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"""Build a PaqueteResponse dict with nested fases and servicios."""
results = await _build_paquetes_responses(conn, [paquete_row])
return results[0]
@router.get("", response_model=list[PaqueteResponse]) @router.get("", response_model=list[PaqueteResponse])
@@ -120,10 +135,7 @@ async def list_paquetes(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"' 'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC' ' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC'
) )
results: list[dict] = [] return await _build_paquetes_responses(conn, [dict(row) for row in rows])
for row in rows:
results.append(await _build_paquete_response(conn, dict(row)))
return results
@router.post( @router.post(
+60
View File
@@ -5,6 +5,19 @@ from datetime import date, datetime
IVA_RATE = 0.16 IVA_RATE = 0.16
# Tarifa por hora sugerida para partidas personalizadas (Hora Centinela).
TARIFA_HORA_DEFAULT = 700
# Modelos de cobro de una partida (espejo del frontend TS).
MODELOS_COBRO: dict[str, str] = {
"fijo": "Precio fijo",
"horas": "Por horas",
"retainer": "Retainer (minimo + adicionales)",
}
# Doble propuesta: cada partida va en la Opcion 1, la 2 o en ambas (espejo del TS).
OPCIONES = ("1", "2", "ambas")
FASES: dict[int, str] = { FASES: dict[int, str] = {
0: "FASE 0 - Auditoria / Acompanamiento", 0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura", 1: "FASE 1 - Setup e Infraestructura",
@@ -45,6 +58,53 @@ BONOS = [
] ]
def calcular_precio_horas(horas: float, tarifa_hora: float) -> float:
return round((horas or 0) * (tarifa_hora or 0), 2)
def describir_retainer(monto_minimo: float, horas_incluidas: float, tarifa_hora: float) -> str:
"""Texto descriptivo de un retainer (espejo de describirRetainer en TS)."""
partes = [f"{format_currency(monto_minimo or 0)}/mes"]
if horas_incluidas:
partes.append(f"incluye {horas_incluidas:g} hr")
if tarifa_hora:
partes.append(f"adicional {format_currency(tarifa_hora)}/hr (se factura aparte)")
return " · ".join(partes)
def detalle_modelo(serv: dict) -> str:
"""Sub-linea de desglose por modelo de cobro para PDF/Excel."""
modelo = serv.get("modeloCobro")
if modelo == "retainer":
return describir_retainer(
serv.get("montoMinimo") or 0,
serv.get("horasIncluidas") or 0,
serv.get("tarifaHora") or 0,
)
horas = serv.get("horas")
tarifa = serv.get("tarifaHora")
if (modelo == "horas" or serv.get("esPersonalizado")) and horas and tarifa:
return f"{horas:g} h x {format_currency(tarifa)}/hr"
return ""
def calcular_totales_opcion(servicios: list[dict], opcion: str) -> dict:
"""Totales de una opcion: suma sus partidas + las marcadas 'ambas'.
Espejo de calcularTotalesOpcion en TS. Respeta la regla de retainer: el total
usa `precio`, no `horas x tarifa`. Las horas son informativas.
"""
rel = [s for s in servicios if s.get("opcion") == "ambas" or s.get("opcion") == opcion]
total_unico = sum((s.get("precio") or 0) for s in rel if s.get("tipoPago") == "unico")
total_mensual = sum((s.get("precio") or 0) for s in rel if s.get("tipoPago") == "mensual")
horas = sum((s.get("horas") or 0) for s in rel)
return {
"totalUnico": round(total_unico, 2),
"totalMensual": round(total_mensual, 2),
"horas": round(horas, 2),
}
def bucefalo_precio(nivel: str) -> float: def bucefalo_precio(nivel: str) -> float:
for p in PLANES_BUCEFALO: for p in PLANES_BUCEFALO:
if p["nivel"] == nivel: if p["nivel"] == nivel:
+323 -125
View File
@@ -1,26 +1,27 @@
"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl.""" """Excel Generator for Cotizador E3 — port of src/lib/excel-builder.ts using openpyxl.
Mantiene paridad de layout con el builder de TypeScript: mismas columnas, merges,
celdas dinámicas (wrapText + altura calculada) para que el texto nunca se salga.
"""
from __future__ import annotations from __future__ import annotations
import io import io
import math
import re import re
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter from openpyxl.utils import column_index_from_string, get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from app.services.calculators import FASES_SHORT, bucefalo_precio from app.services.calculators import bucefalo_precio, detalle_modelo, calcular_totales_opcion, format_currency
FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"} 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: def _argb(hex_color: str, alpha: str = "FF") -> str:
h = hex_color.lstrip("#") h = hex_color.lstrip("#")
if len(h) > 6: if len(h) > 6:
@@ -34,15 +35,58 @@ def _thin_border(color: str = "FFD1D5DB") -> Border:
def _sanitize_sheet_name(name: str) -> str: def _sanitize_sheet_name(name: str) -> str:
name = name[:31] name = (name or "Servicio")[:31]
return re.sub(r"[/\\*?\[\]:]", "", name) return re.sub(r"[/\\*?\[\]:]", "", name) or "Servicio"
def _set_col_widths(ws, widths: list[tuple[str, float]]): def _set_col_widths(ws: Worksheet, widths: list[tuple[str, float]]):
for col_letter, width in widths: for col_letter, width in widths:
ws.column_dimensions[col_letter].width = width ws.column_dimensions[col_letter].width = width
def _merged_width(widths: dict[str, float], start_col: str, end_col: str) -> float:
"""Suma de anchos (unidades de carácter de Excel) entre dos columnas, inclusive."""
start = column_index_from_string(start_col)
end = column_index_from_string(end_col)
return sum(widths.get(get_column_letter(c), 8) for c in range(start, end + 1))
def _estimate_row_height(text: str, width_chars: float, font_size: int = 10) -> float:
"""Altura (pt) que necesita una fila para mostrar `text` envuelto en `width_chars`."""
cpl = max(8, int(width_chars))
if font_size <= 10:
line_h = 14.0
elif font_size <= 11:
line_h = 15.5
elif font_size <= 12:
line_h = 17.0
else:
line_h = 20.0
lines = 0
for seg in str(text or "").split("\n"):
lines += max(1, math.ceil(len(seg) / cpl))
return max(16.0, round(lines * line_h + 4))
def _set_wrapped(
ws: Worksheet,
row: int,
col: str,
text: str,
width_chars: float,
font_size: int = 10,
horizontal: str = "left",
vertical: str = "top",
):
"""Marca la celda como envolvente y agranda la fila si el texto lo requiere."""
cell = ws[f"{col}{row}"]
cell.alignment = Alignment(wrap_text=True, vertical=vertical, horizontal=horizontal)
needed = _estimate_row_height(text, width_chars, font_size)
current = ws.row_dimensions[row].height or 0
if needed > current:
ws.row_dimensions[row].height = needed
def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes: def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes:
"""Generate a multi-sheet Excel workbook for a quotation. """Generate a multi-sheet Excel workbook for a quotation.
@@ -66,6 +110,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
total_font = Font(bold=True, size=11, name="Calibri") total_font = Font(bold=True, size=11, name="Calibri")
small_font = Font(italic=True, size=9, name="Calibri", color=MUTED) small_font = Font(italic=True, size=9, name="Calibri", color=MUTED)
fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY) fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY)
iva_font = Font(italic=False, size=9, name="Calibri", color=MUTED)
primary_fill = PatternFill(start_color=PRIMARY, end_color=PRIMARY, fill_type="solid") 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") primary_light_fill = PatternFill(start_color=PRIMARY_LIGHT, end_color=PRIMARY_LIGHT, fill_type="solid")
@@ -74,6 +119,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid") white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid")
razon_social = data.get("razonSocial", "Cotizador E3") razon_social = data.get("razonSocial", "Cotizador E3")
domicilio_fiscal = data.get("domicilioFiscal")
cliente_nombre = data.get("clienteNombre", "") cliente_nombre = data.get("clienteNombre", "")
cliente_empresa = data.get("clienteEmpresa", "") cliente_empresa = data.get("clienteEmpresa", "")
asesor_nombre = data.get("asesorNombre", "") asesor_nombre = data.get("asesorNombre", "")
@@ -85,24 +131,26 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
esquema = data.get("esquemaPago", "Pago Unico/Mensual") esquema = data.get("esquemaPago", "Pago Unico/Mensual")
servicios = data.get("servicios", []) servicios = data.get("servicios", [])
plan_nivel = data.get("planBucefaloNivel") plan_nivel = data.get("planBucefaloNivel")
plan_precio = data.get("planBucefaloPrecio")
numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR" numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR"
empresa = cliente_empresa or cliente_nombre
wb = Workbook() wb = Workbook()
wb.properties.creator = "Cotizador E3" wb.properties.creator = "Cotizador E3"
# ── HOJA RESUMEN ────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════
# HOJA RESUMEN
# ═══════════════════════════════════════════════════════════════════════════
ws = wb.active ws = wb.active
ws.title = "HOJA RESUMEN" ws.title = "HOJA RESUMEN"
ws.sheet_view.showGridLines = False ws.sheet_view.showGridLines = False
_set_col_widths(ws, [ resumen_widths = {
("A", 3), ("B", 22), ("C", 22), ("D", 16), "A": 3, "B": 18, "C": 26, "D": 13, "E": 16,
("E", 38), ("F", 5), ("G", 20), ("H", 5), "F": 22, "G": 6, "H": 6, "I": 6, "J": 6, "K": 16,
("I", 20), ("J", 5), ("K", 18), }
]) _set_col_widths(ws, list(resumen_widths.items()))
# Header banner # Banner
ws.merge_cells("B2:K2") ws.merge_cells("B2:K2")
cell = ws["B2"] cell = ws["B2"]
cell.value = razon_social cell.value = razon_social
@@ -110,6 +158,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
cell.alignment = Alignment(vertical="center") cell.alignment = Alignment(vertical="center")
for col in range(1, 12): for col in range(1, 12):
ws.cell(row=2, column=col).fill = primary_fill ws.cell(row=2, column=col).fill = primary_fill
ws.row_dimensions[2].height = 28
ws.merge_cells("B3:K3") ws.merge_cells("B3:K3")
cell = ws["B3"] cell = ws["B3"]
@@ -122,217 +171,366 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
# Info section # Info section
info_start = 5 info_start = 5
info_rows = [ info_rows = [
[("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")], [("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "")],
[("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)], [("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)],
[("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)], [("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)],
[("B", "Fecha:"), ("C", fecha), ("E", "Esquema de Pago:"), ("F", esquema)], [("B", "Fecha:"), ("C", ""), ("E", "Esquema de Pago:"), ("F", esquema)],
] ]
c_val_w = _merged_width(resumen_widths, "C", "C")
f_val_w = _merged_width(resumen_widths, "F", "F")
for i, row_data in enumerate(info_rows): for i, row_data in enumerate(info_rows):
r = info_start + i r = info_start + i
for col_letter, label, is_value in [ (lc1, lv1), (vc1, vv1), (lc2, lv2), (vc2, vv2) = row_data
(row_data[0][0], row_data[0][1], False), ws[f"{lc1}{r}"].value = lv1
(row_data[1][0], row_data[1][1], True), ws[f"{lc1}{r}"].font = label_font
(row_data[2][0], row_data[2][1], False), ws[f"{vc1}{r}"].value = vv1
(row_data[3][0], row_data[3][1], True), ws[f"{vc1}{r}"].font = value_font
]: if vv1:
cell = ws[f"{col_letter}{r}"] _set_wrapped(ws, r, vc1, vv1, c_val_w)
cell.value = label ws[f"{lc2}{r}"].value = lv2
cell.font = value_font if is_value else label_font ws[f"{lc2}{r}"].font = label_font
ws[f"{vc2}{r}"].value = vv2
ws[f"{vc2}{r}"].font = value_font
if vv2:
_set_wrapped(ws, r, vc2, vv2, f_val_w)
# Vigencia # Fecha (en la fila "Fecha:")
vigencia_row = info_start + 4 fecha_cell = ws[f"C{info_start + 3}"]
ws[f"B{vigencia_row}"].value = "Vigencia:" fecha_cell.value = fecha
ws[f"B{vigencia_row}"].font = label_font fecha_cell.number_format = "DD/MM/YYYY"
ws[f"C{vigencia_row}"].value = vigencia fecha_cell.font = value_font
ws[f"C{vigencia_row}"].number_format = "DD/MM/YYYY"
ws[f"C{vigencia_row}"].font = value_font vig_row = info_start + 4
ws[f"B{vig_row}"].value = "Vigencia:"
ws[f"B{vig_row}"].font = label_font
ws[f"C{vig_row}"].value = vigencia
ws[f"C{vig_row}"].number_format = "DD/MM/YYYY"
ws[f"C{vig_row}"].font = value_font
# Services table # Services table
table_start = info_start + 6 table_start = info_start + 6
cols = ["B", "C", "D", "K"] col_b = column_index_from_string("B")
headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"] col_k = column_index_from_string("K")
def style_table_row(row_num: int, fill: PatternFill, border_color: str):
"""Estiliza (relleno + borde) todo el ancho de la fila, de B a K."""
border = _thin_border(border_color)
for c in range(col_b, col_k + 1):
cell = ws.cell(row=row_num, column=c)
cell.fill = fill
cell.border = border
ws.merge_cells(f"D{table_start}:J{table_start}") ws.merge_cells(f"D{table_start}:J{table_start}")
for i, col_letter in enumerate(cols): style_table_row(table_start, primary_fill, PRIMARY)
for col_letter, txt, align in [
("B", "Fase", "left"),
("C", "Tipo de Pago", "left"),
("D", "Servicio", "left"),
("K", "Precio", "right"),
]:
cell = ws[f"{col_letter}{table_start}"] cell = ws[f"{col_letter}{table_start}"]
cell.value = headers[i] cell.value = txt
cell.font = header_font cell.font = header_font
cell.fill = primary_fill cell.alignment = Alignment(horizontal=align, vertical="center")
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 ws.row_dimensions[table_start].height = 24
servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"] servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"] servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
serv_width = _merged_width(resumen_widths, "D", "J")
es_doble = bool(data.get("esDoble"))
opciones_meta = data.get("opcionesMetadata") or {}
row = table_start + 1 row = table_start + 1
current_fase = -1
for serv in servicios_unicos + servicios_mensuales: # Totales (helpers reutilizados por modo normal y doble propuesta)
def write_total_row(label: str, amount: float, font: Font):
nonlocal row
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = label
ws[f"B{row}"].font = font
ws[f"B{row}"].alignment = Alignment(vertical="center")
ws[f"K{row}"].value = amount
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = font
ws[f"K{row}"].alignment = Alignment(horizontal="right", vertical="center")
style_table_row(row, primary_light_fill, PRIMARY)
row += 1
def write_iva_note():
nonlocal row
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "(+ IVA)"
ws[f"B{row}"].font = iva_font
row += 1
def draw_service_rows(servs):
nonlocal row
current_fase = -1
for serv in servs:
fase = serv.get("fase", 0) fase = serv.get("fase", 0)
if fase != current_fase: if fase != current_fase:
current_fase = fase current_fase = fase
ws.merge_cells(f"B{row}:K{row}") ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}") ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}")
ws[f"B{row}"].font = fase_font ws[f"B{row}"].font = fase_font
ws[f"B{row}"].alignment = Alignment(vertical="center")
for col in range(2, 12): for col in range(2, 12):
ws.cell(row=row, column=col).fill = primary_light_fill ws.cell(row=row, column=col).fill = primary_light_fill
ws.row_dimensions[row].height = 20
row += 1 row += 1
row_bg = light_fill if row % 2 == 0 else white_fill row_bg = light_fill if row % 2 == 0 else white_fill
ws.merge_cells(f"D{row}:J{row}")
ws[f"B{row}"].value = "" style_table_row(row, row_bg, BORDER_COLOR)
ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual" ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
ws[f"C{row}"].font = value_font ws[f"C{row}"].font = value_font
ws.merge_cells(f"D{row}:J{row}") ws[f"C{row}"].alignment = Alignment(vertical="top")
ws[f"D{row}"].value = serv.get("nombre", "") _detalle = detalle_modelo(serv)
serv_text = f"{serv.get('nombre', '')}{_detalle}" if _detalle else serv.get("nombre", "")
ws[f"D{row}"].value = serv_text
ws[f"D{row}"].font = value_font ws[f"D{row}"].font = value_font
_set_wrapped(ws, row, "D", serv_text, serv_width)
ws[f"K{row}"].value = serv.get("precio", 0) ws[f"K{row}"].value = serv.get("precio", 0)
ws[f"K{row}"].number_format = "$#,##0.00" ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = value_font ws[f"K{row}"].font = value_font
ws[f"K{row}"].alignment = Alignment(horizontal="right") ws[f"K{row}"].alignment = Alignment(horizontal="right", vertical="top")
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
def write_opcion_banner(op: str):
nonlocal row
meta = opciones_meta.get(op) or {}
ws.merge_cells(f"B{row}:K{row}")
titulo = meta.get("titulo")
ws[f"B{row}"].value = f"OPCION {op}" + (f": {titulo}" if titulo else "")
ws[f"B{row}"].font = Font(bold=True, size=12, name="Calibri", color=WHITE)
ws[f"B{row}"].alignment = Alignment(vertical="center")
for col in range(2, 12):
ws.cell(row=row, column=col).fill = secondary_fill
ws.row_dimensions[row].height = 22
row += 1 row += 1
total_unico = sum(s.get("precio", 0) for s in servicios_unicos) desc = meta.get("descripcion")
ws.merge_cells(f"B{row}:J{row}") if desc:
ws[f"B{row}"].value = "Total Pago Unico" ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].font = total_font ws[f"B{row}"].value = desc
ws[f"K{row}"].value = total_unico ws[f"B{row}"].font = value_font
ws[f"K{row}"].number_format = "$#,##0.00" _set_wrapped(ws, row, "B", desc, _merged_width(resumen_widths, "B", "K"))
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 row += 1
ws.merge_cells(f"B{row}:J{row}") no_incluye = meta.get("noIncluye")
ws[f"B{row}"].value = "(+ IVA)" if no_incluye:
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED) ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = f"No incluye: {no_incluye}"
ws[f"B{row}"].font = small_font
_set_wrapped(ws, row, "B", f"No incluye: {no_incluye}", _merged_width(resumen_widths, "B", "K"), font_size=9)
row += 1 row += 1
total_mensual = sum(s.get("precio", 0) for s in servicios_mensuales) if es_doble:
ws.merge_cells(f"B{row}:J{row}") for op in ("1", "2"):
ws[f"B{row}"].value = "Total Pago Mensual" u = [s for s in servicios_unicos if s.get("opcion") in (op, "ambas")]
ws[f"B{row}"].font = total_font me = [s for s in servicios_mensuales if s.get("opcion") in (op, "ambas")]
ws[f"K{row}"].value = total_mensual write_opcion_banner(op)
ws[f"K{row}"].number_format = "$#,##0.00" draw_service_rows(u + me)
ws[f"K{row}"].font = total_font row += 1
ws[f"K{row}"].alignment = Alignment(horizontal="right") write_total_row(f"Total Pago Unico - Opcion {op}", sum(s.get("precio", 0) for s in u), total_font)
for col_letter in cols: write_iva_note()
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY) write_total_row(f"Total Pago Mensual - Opcion {op}", sum(s.get("precio", 0) for s in me), total_font)
ws[f"{col_letter}{row}"].fill = primary_light_fill write_iva_note()
row += 1
# Comparativa de opciones (totales con IVA + horas)
t1 = calcular_totales_opcion(servicios, "1")
t2 = calcular_totales_opcion(servicios, "2")
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = "COMPARATIVA DE OPCIONES"
ws[f"B{row}"].font = Font(bold=True, size=12, name="Calibri", color=WHITE)
ws[f"B{row}"].alignment = Alignment(vertical="center")
for col in range(2, 12):
ws.cell(row=row, column=col).fill = primary_fill
ws.row_dimensions[row].height = 22
row += 1
t1_tit = (opciones_meta.get("1") or {}).get("titulo")
t2_tit = (opciones_meta.get("2") or {}).get("titulo")
def comp_row(lab: str, v1: str, v2: str, font: Font):
nonlocal row
ws.merge_cells(f"B{row}:F{row}")
ws[f"B{row}"].value = lab
ws[f"B{row}"].font = font
ws.merge_cells(f"G{row}:H{row}")
ws[f"G{row}"].value = v1
ws[f"G{row}"].font = font
ws[f"G{row}"].alignment = Alignment(horizontal="right")
ws.merge_cells(f"I{row}:K{row}")
ws[f"I{row}"].value = v2
ws[f"I{row}"].font = font
ws[f"I{row}"].alignment = Alignment(horizontal="right")
border = _thin_border(BORDER_COLOR)
for col in range(col_b, col_k + 1):
ws.cell(row=row, column=col).border = border
row += 1
comp_row("Concepto", "Opcion 1" + (f" - {t1_tit}" if t1_tit else ""), "Opcion 2" + (f" - {t2_tit}" if t2_tit else ""), bold_font)
comp_row("Total unico (c/IVA)", format_currency(t1["totalUnico"] * 1.16), format_currency(t2["totalUnico"] * 1.16), value_font)
comp_row("Total mensual (c/IVA)", format_currency(t1["totalMensual"] * 1.16), format_currency(t2["totalMensual"] * 1.16), value_font)
comp_row("Horas estimadas", f"{t1['horas']:g} h", f"{t2['horas']:g} h", value_font)
row += 1
else:
draw_service_rows(servicios_unicos + servicios_mensuales)
row += 1
write_total_row("Total Pago Unico", sum(s.get("precio", 0) for s in servicios_unicos), total_font)
write_iva_note()
write_total_row("Total Pago Mensual", sum(s.get("precio", 0) for s in servicios_mensuales), total_font)
write_iva_note()
row += 1 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: if plan_nivel:
ws.merge_cells(f"B{row}:J{row}") precio = plan_precio if plan_precio is not None else bucefalo_precio(plan_nivel)
ws[f"B{row}"].value = f"CRM Bucefalo - {plan_nivel.capitalize()}" write_total_row(f"CRM Bucefalo - {plan_nivel.capitalize()}", precio, bold_font)
ws[f"B{row}"].font = bold_font row += 1
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.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." nota_resumen = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
ws[f"B{row}"].value = nota_resumen
ws[f"B{row}"].font = small_font ws[f"B{row}"].font = small_font
_set_wrapped(ws, row, "B", nota_resumen, _merged_width(resumen_widths, "B", "K"), font_size=9)
# ── DETAIL SHEETS ────────────────────────────── # ═══════════════════════════════════════════════════════════════════════════
# HOJAS DETALLADAS POR SERVICIO
# ═══════════════════════════════════════════════════════════════════════════
detail_widths = {
"A": 3, "B": 16, "C": 30, "D": 8, "E": 4,
"F": 18, "G": 22, "H": 6, "I": 6, "J": 16,
}
used_names: set[str] = set()
for serv in servicios: for serv in servicios:
safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio")) base = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
safe_name = base
n = 2
while safe_name.lower() in used_names:
suffix = f" ({n})"
safe_name = base[: 31 - len(suffix)] + suffix
n += 1
used_names.add(safe_name.lower())
dw = wb.create_sheet(title=safe_name) dw = wb.create_sheet(title=safe_name)
dw.sheet_view.showGridLines = False dw.sheet_view.showGridLines = False
_set_col_widths(dw, list(detail_widths.items()))
_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) fase = serv.get("fase", 0)
dw.merge_cells("B1:J1") dw.merge_cells("B1:J1")
dw["B1"].value = f"{FASES_MAP.get(fase, f'FASE {fase}')} \u2014 {serv.get('nombre', '')}" banner_text = f"{FASES_MAP.get(fase, f'FASE {fase}')} {serv.get('nombre', '')}"
dw["B1"].value = banner_text
dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE) dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE)
dw["B1"].alignment = Alignment(vertical="center") dw["B1"].alignment = Alignment(vertical="center", wrap_text=True)
for col in range(1, 11): for col in range(1, 11):
dw.cell(row=1, column=col).fill = primary_fill dw.cell(row=1, column=col).fill = primary_fill
dw.row_dimensions[1].height = 30 dw.row_dimensions[1].height = max(
30, _estimate_row_height(banner_text, _merged_width(detail_widths, "B", "J"), 14)
)
c_val = _merged_width(detail_widths, "C", "D")
g_val = _merged_width(detail_widths, "G", "H")
r = 3 r = 3
dw[f"B{r}"].value = "Cliente:" dw[f"B{r}"].value = "Cliente:"
dw[f"B{r}"].font = label_font dw[f"B{r}"].font = label_font
dw.merge_cells(f"C{r}:D{r}")
dw[f"C{r}"].value = cliente_empresa or cliente_nombre dw[f"C{r}"].value = cliente_empresa or cliente_nombre
dw[f"C{r}"].font = bold_font dw[f"C{r}"].font = bold_font
_set_wrapped(dw, r, "C", cliente_empresa or cliente_nombre, c_val)
dw[f"F{r}"].value = "No. Cotizacion:" dw[f"F{r}"].value = "No. Cotizacion:"
dw[f"F{r}"].font = label_font dw[f"F{r}"].font = label_font
dw.merge_cells(f"G{r}:H{r}")
dw[f"G{r}"].value = numero_label dw[f"G{r}"].value = numero_label
dw[f"G{r}"].font = bold_font dw[f"G{r}"].font = bold_font
r += 2 r += 2
dw[f"B{r}"].value = "Servicio:" dw[f"B{r}"].value = "Servicio:"
dw[f"B{r}"].font = label_font dw[f"B{r}"].font = label_font
dw.merge_cells(f"C{r}:D{r}")
dw[f"C{r}"].value = serv.get("nombre", "") dw[f"C{r}"].value = serv.get("nombre", "")
dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY) dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY)
_set_wrapped(dw, r, "C", serv.get("nombre", ""), c_val, font_size=12)
dw[f"F{r}"].value = "Tiempo de Entrega:" dw[f"F{r}"].value = "Tiempo de Entrega:"
dw[f"F{r}"].font = label_font dw[f"F{r}"].font = label_font
dw.merge_cells(f"G{r}:H{r}")
dw[f"G{r}"].value = serv.get("tiempoEntrega", "") dw[f"G{r}"].value = serv.get("tiempoEntrega", "")
dw[f"G{r}"].font = value_font dw[f"G{r}"].font = value_font
_set_wrapped(dw, r, "G", serv.get("tiempoEntrega", ""), g_val)
r += 2 r += 2
# Entregables header _detalle_serv = detalle_modelo(serv)
for col_letter in ["B", "I"]: if _detalle_serv:
dw[f"{col_letter}{r}"].fill = primary_fill dw[f"B{r}"].value = "Detalle:"
dw[f"{col_letter}{r}"].font = header_font dw[f"B{r}"].font = label_font
dw[f"B{r}"].value = "Entregable" dw.merge_cells(f"C{r}:J{r}")
dw[f"C{r}"].value = _detalle_serv
dw[f"C{r}"].font = value_font
_set_wrapped(dw, r, "C", _detalle_serv, _merged_width(detail_widths, "C", "J"))
r += 2
# Encabezado de entregables — barra completa B:J
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = "Entregables"
dw[f"B{r}"].font = header_font
dw[f"B{r}"].alignment = Alignment(vertical="center")
for col in range(column_index_from_string("B"), column_index_from_string("J") + 1):
dw.cell(row=r, column=col).fill = primary_fill
dw.row_dimensions[r].height = 22
r += 1 r += 1
ent_width = _merged_width(detail_widths, "B", "J")
entregables = serv.get("entregables", []) entregables = serv.get("entregables", [])
b_idx = column_index_from_string("B")
j_idx = column_index_from_string("J")
for i, ent in enumerate(entregables): for i, ent in enumerate(entregables):
er = r + i
dw.merge_cells(f"B{er}:J{er}")
txt = f"{i + 1}. {ent}"
dw[f"B{er}"].value = txt
dw[f"B{er}"].font = value_font
_set_wrapped(dw, er, "B", txt, ent_width)
bg = light_fill if i % 2 == 1 else white_fill bg = light_fill if i % 2 == 1 else white_fill
dw[f"B{i + r}"].value = f"{i + 1}. {ent}" for col in range(b_idx, j_idx + 1):
dw[f"B{i + r}"].font = value_font dw.cell(row=er, column=col).fill = bg
dw[f"B{i + r}"].fill = bg dw.cell(row=er, column=col).border = _thin_border(BORDER_COLOR)
dw[f"B{i + r}"].border = _thin_border(BORDER_COLOR)
r += len(entregables) + 1 r += len(entregables) + 1
# Total # Total: etiqueta B:H + precio I:J
for col_letter in ["B", "I"]: dw.merge_cells(f"B{r}:H{r}")
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" tipo_label = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
dw[f"B{r}"].value = f"Total Pago {tipo_label}" dw[f"B{r}"].value = f"Total Pago {tipo_label}"
dw[f"B{r}"].font = total_font dw[f"B{r}"].font = total_font
dw[f"B{r}"].alignment = Alignment(vertical="center")
dw.merge_cells(f"I{r}:J{r}")
dw[f"I{r}"].value = serv.get("precio", 0) dw[f"I{r}"].value = serv.get("precio", 0)
dw[f"I{r}"].number_format = "$#,##0.00" dw[f"I{r}"].number_format = "$#,##0.00"
dw[f"I{r}"].font = total_font dw[f"I{r}"].font = total_font
dw[f"I{r}"].alignment = Alignment(horizontal="right") dw[f"I{r}"].alignment = Alignment(horizontal="right", vertical="center")
for col in range(b_idx, j_idx + 1):
dw.cell(row=r, column=col).fill = primary_light_fill
dw.cell(row=r, column=col).border = _thin_border(PRIMARY)
r += 1 r += 1
dw[f"B{r}"].value = "(+ IVA)" dw[f"B{r}"].value = "(+ IVA)"
dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED) dw[f"B{r}"].font = iva_font
r += 2 r += 2
dw.merge_cells(f"B{r}:J{r}") 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." nota_detalle = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."
dw[f"B{r}"].value = nota_detalle
dw[f"B{r}"].font = small_font dw[f"B{r}"].font = small_font
_set_wrapped(dw, r, "B", nota_detalle, ent_width, font_size=9)
r += 2 r += 2
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = razon_social dw[f"B{r}"].value = razon_social
dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY) dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY)
if domicilio_fiscal:
r += 1
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = domicilio_fiscal
dw[f"B{r}"].font = iva_font
_set_wrapped(dw, r, "B", domicilio_fiscal, ent_width, font_size=9)
output = io.BytesIO() output = io.BytesIO()
wb.save(output) wb.save(output)
+126 -11
View File
@@ -13,7 +13,7 @@ from reportlab.lib.units import inch
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio, detalle_modelo, calcular_totales_opcion
PRIMARY_DEFAULT = "#2563eb" PRIMARY_DEFAULT = "#2563eb"
DARK_DEFAULT = "#1e293b" DARK_DEFAULT = "#1e293b"
@@ -40,6 +40,23 @@ def _fmt_currency(n: float) -> str:
return f"${n:,.2f}" return f"${n:,.2f}"
def _wrap_text(c, text: str, font: str, size: float, max_width: float) -> list[str]:
"""Envuelve texto en lineas que caben en max_width (respeta saltos de linea)."""
lines: list[str] = []
for paragraph in str(text or "").split("\n"):
words = paragraph.split()
line = ""
for word in words:
test = f"{line} {word}".strip()
if c.stringWidth(test, font, size) > max_width and line:
lines.append(line)
line = word
else:
line = test
lines.append(line)
return lines
def _fmt_date(d: datetime) -> str: def _fmt_date(d: datetime) -> str:
months = [ months = [
"enero", "febrero", "marzo", "abril", "mayo", "junio", "enero", "febrero", "marzo", "abril", "mayo", "junio",
@@ -187,21 +204,23 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
col_tiempo = MARGIN_LEFT + CONTENT_W - 120 col_tiempo = MARGIN_LEFT + CONTENT_W - 120
col_precio = MARGIN_LEFT + CONTENT_W - 60 col_precio = MARGIN_LEFT + CONTENT_W - 60
# Header row es_doble = bool(data.get("esDoble"))
opciones_meta = data.get("opcionesMetadata") or {}
def _draw_table_header(y_pos):
c.setFillColor(light_rgb) c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 16, fill=1, stroke=0) c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7) c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb) c.setFillColor(dark_rgb)
c.drawString(col_nombre + 6, y + 3, "Servicio") c.drawString(col_nombre + 6, y_pos + 3, "Servicio")
c.drawString(col_tipo + 4, y + 3, "Tipo") c.drawString(col_tipo + 4, y_pos + 3, "Tipo")
c.drawString(col_tiempo + 4, y + 3, "Entrega") c.drawString(col_tiempo + 4, y_pos + 3, "Entrega")
c.drawRightString(col_precio + 60, y + 3, "Precio") c.drawRightString(col_precio + 60, y_pos + 3, "Precio")
y -= 4 y_pos -= 4
c.setStrokeColor(border_rgb) c.setStrokeColor(border_rgb)
c.setLineWidth(0.3) c.setLineWidth(0.3)
c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y) c.line(MARGIN_LEFT, y_pos, MARGIN_LEFT + CONTENT_W, y_pos)
y -= 14 return y_pos - 14
unicos = [s for s in servicios if s.get("tipoPago") == "unico"] unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"] mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
@@ -239,6 +258,13 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0))) c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0)))
y_pos -= 14 y_pos -= 14
detalle = detalle_modelo(serv)
if detalle:
c.setFont("Helvetica-Oblique", 6.5)
c.setFillColor(muted_rgb)
c.drawString(col_nombre + 10, y_pos - 7, detalle[:90])
y_pos -= 9
entregables = serv.get("entregables", []) entregables = serv.get("entregables", [])
if entregables: if entregables:
half = (len(entregables) + 1) // 2 half = (len(entregables) + 1) // 2
@@ -272,6 +298,95 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
y_pos -= 14 y_pos -= 14
return y_pos return y_pos
def _draw_opcion_header(op, y_pos):
meta = opciones_meta.get(op) or {}
max_y = PAGE_H - MARGIN_BOTTOM - 5
if y_pos - 30 < max_y:
c.showPage()
y_pos = PAGE_H - MARGIN_TOP
titulo = meta.get("titulo")
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y_pos - 18, CONTENT_W, 18, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 10)
c.setFillColor(white_rgb)
c.drawString(MARGIN_LEFT + 8, y_pos - 13, f"OPCION {op}" + (f": {titulo}" if titulo else ""))
y_pos -= 24
desc = meta.get("descripcion")
if desc:
c.setFont("Helvetica", 7.5)
c.setFillColor(dark_rgb)
for line in _wrap_text(c, desc, "Helvetica", 7.5, CONTENT_W - 12):
c.drawString(MARGIN_LEFT + 6, y_pos - 8, line)
y_pos -= 10
y_pos -= 2
no_incluye = meta.get("noIncluye")
if no_incluye:
c.setFont("Helvetica-Oblique", 7)
c.setFillColor(muted_rgb)
for line in _wrap_text(c, f"No incluye: {no_incluye}", "Helvetica-Oblique", 7, CONTENT_W - 12):
c.drawString(MARGIN_LEFT + 6, y_pos - 8, line)
y_pos -= 10
y_pos -= 4
return y_pos
def _draw_comparativa(y_pos):
t1 = calcular_totales_opcion(servicios, "1")
t2 = calcular_totales_opcion(servicios, "2")
max_y = PAGE_H - MARGIN_BOTTOM - 5
if y_pos - 90 < max_y:
c.showPage()
y_pos = PAGE_H - MARGIN_TOP
y_pos -= 6
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y_pos - 10, 3, 10, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 10)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, y_pos - 8, "Comparativa de opciones")
y_pos -= 22
c_label = MARGIN_LEFT
c_op1 = MARGIN_LEFT + CONTENT_W * 0.45
c_op2 = MARGIN_LEFT + CONTENT_W * 0.72
t1_tit = (opciones_meta.get("1") or {}).get("titulo")
t2_tit = (opciones_meta.get("2") or {}).get("titulo")
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7.5)
c.setFillColor(dark_rgb)
c.drawString(c_label + 6, y_pos + 3, "Concepto")
c.drawString(c_op1, y_pos + 3, f"Opcion 1" + (f" - {t1_tit}" if t1_tit else ""))
c.drawString(c_op2, y_pos + 3, f"Opcion 2" + (f" - {t2_tit}" if t2_tit else ""))
y_pos -= 18
filas = [
("Total unico (c/IVA)", _fmt_currency(t1["totalUnico"] * 1.16), _fmt_currency(t2["totalUnico"] * 1.16)),
("Total mensual (c/IVA)", _fmt_currency(t1["totalMensual"] * 1.16), _fmt_currency(t2["totalMensual"] * 1.16)),
("Horas estimadas", f"{t1['horas']:g} h", f"{t2['horas']:g} h"),
]
for lab, v1, v2 in filas:
c.setFont("Helvetica", 8)
c.setFillColor(dark_rgb)
c.drawString(c_label + 6, y_pos - 8, lab)
c.setFont("Helvetica-Bold", 8)
c.drawString(c_op1, y_pos - 8, v1)
c.drawString(c_op2, y_pos - 8, v2)
c.setStrokeColor(_hex_to_rgb("#e5e7eb"))
c.setLineWidth(0.2)
c.line(c_label + 6, y_pos - 12, MARGIN_LEFT + CONTENT_W, y_pos - 12)
y_pos -= 14
return y_pos - 8
if es_doble:
for op in ("1", "2"):
y = _draw_opcion_header(op, y)
y = _draw_table_header(y)
u = [s for s in unicos if s.get("opcion") in (op, "ambas")]
me = [s for s in mensuales if s.get("opcion") in (op, "ambas")]
if u:
y = _draw_section(u, "Unico", f"Total Pago Unico - Opcion {op}", y)
if me:
y = _draw_section(me, "Mensual", f"Total Pago Mensual - Opcion {op}", y)
y = _draw_comparativa(y)
else:
y = _draw_table_header(y)
if unicos: if unicos:
y = _draw_section(unicos, "Unico", "Total Pago Unico", y) y = _draw_section(unicos, "Unico", "Total Pago Unico", y)
if mensuales: if mensuales:
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
serverExternalPackages: ["pdfkit"], serverExternalPackages: ["pdfkit"],
poweredByHeader: false,
}; };
export default nextConfig; export default nextConfig;
+29 -2
View File
@@ -29,6 +29,8 @@ model Cliente {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
cotizaciones Cotizacion[] cotizaciones Cotizacion[]
@@index([nombre, empresa])
} }
model Cotizacion { model Cotizacion {
@@ -43,6 +45,8 @@ model Cotizacion {
estado String @default("borrador") estado String @default("borrador")
incluirBonos Boolean @default(false) incluirBonos Boolean @default(false)
incluirFinanciamiento Boolean @default(false) incluirFinanciamiento Boolean @default(false)
esDoble Boolean @default(false)
opcionesMetadata Json?
observaciones String? observaciones String?
clienteId String clienteId String
asesorId String asesorId String
@@ -53,6 +57,11 @@ model Cotizacion {
asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id]) asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id])
servicios ServicioCotizado[] servicios ServicioCotizado[]
planBucefalo PlanBucefaloCotizacion? planBucefalo PlanBucefaloCotizacion?
@@index([clienteId])
@@index([asesorId])
@@index([estado])
@@index([createdAt])
} }
model Categoria { model Categoria {
@@ -89,6 +98,8 @@ model FasePaquete {
paquete Paquete @relation(fields: [paqueteId], references: [id], onDelete: Cascade) paquete Paquete @relation(fields: [paqueteId], references: [id], onDelete: Cascade)
servicios ServicioPaquete[] servicios ServicioPaquete[]
@@index([paqueteId])
} }
model ServicioCatalogo { model ServicioCatalogo {
@@ -102,6 +113,7 @@ model ServicioCatalogo {
entregablesDefault Json @default("[]") entregablesDefault Json @default("[]")
categoriaId String? categoriaId String?
variante String? variante String?
nivel String?
activo Boolean @default(true) activo Boolean @default(true)
orden Int @default(0) orden Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -110,6 +122,9 @@ model ServicioCatalogo {
servicios ServicioCotizado[] servicios ServicioCotizado[]
categoriaRel Categoria? @relation(fields: [categoriaId], references: [id]) categoriaRel Categoria? @relation(fields: [categoriaId], references: [id])
paquetes ServicioPaquete[] paquetes ServicioPaquete[]
@@index([categoriaId])
@@index([activo, fase, orden])
} }
model ServicioPaquete { model ServicioPaquete {
@@ -123,12 +138,21 @@ model ServicioPaquete {
fasePaquete FasePaquete @relation(fields: [fasePaqueteId], references: [id], onDelete: Cascade) fasePaquete FasePaquete @relation(fields: [fasePaqueteId], references: [id], onDelete: Cascade)
@@unique([servicioCatalogoId, fasePaqueteId]) @@unique([servicioCatalogoId, fasePaqueteId])
@@index([fasePaqueteId])
} }
model ServicioCotizado { model ServicioCotizado {
id String @id @default(cuid()) id String @id @default(cuid())
cotizacionId String cotizacionId String
servicioCatalogoId String servicioCatalogoId String?
nombre String?
esPersonalizado Boolean @default(false)
horas Float?
tarifaHora Float?
modeloCobro String @default("fijo")
montoMinimo Float?
horasIncluidas Float?
opcion String?
fase Int fase Int
tipoPago String tipoPago String
precio Float precio Float
@@ -140,7 +164,10 @@ model ServicioCotizado {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
servicioCatalogo ServicioCatalogo @relation(fields: [servicioCatalogoId], references: [id]) servicioCatalogo ServicioCatalogo? @relation(fields: [servicioCatalogoId], references: [id])
@@index([cotizacionId])
@@index([servicioCatalogoId])
} }
model PlanBucefaloCotizacion { model PlanBucefaloCotizacion {
+1277 -7
View File
File diff suppressed because it is too large Load Diff
+41 -25
View File
@@ -6,14 +6,12 @@ import {
Pencil, Pencil,
Trash2, Trash2,
X, X,
Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Loader2, Loader2,
Package, Package,
LayoutGrid, LayoutGrid,
Settings, Settings,
GripVertical,
Save, Save,
FileDown, FileDown,
Upload, Upload,
@@ -34,6 +32,7 @@ interface Servicio {
categoriaNombre: string; categoriaNombre: string;
categoriaColor: string; categoriaColor: string;
variante: string | null; variante: string | null;
nivel: string | null;
activo: boolean; activo: boolean;
orden: number; orden: number;
} }
@@ -51,7 +50,7 @@ interface FasePaqueteUI {
id: string; id: string;
nombre: string; nombre: string;
orden: number; orden: number;
servicios: { id: string; nombre: string; precioBase: number; tipoPago: string }[]; servicios: { id: string; nombre: string; precioBase: number; tipoPago: string; nivel: string | null }[];
} }
interface PaqueteUI { interface PaqueteUI {
@@ -309,33 +308,36 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
<span className="text-xs text-muted">({catServs.length})</span> <span className="text-xs text-muted">({catServs.length})</span>
</div> </div>
<div className="bg-card-bg rounded-xl border border-border overflow-hidden"> <div className="bg-card-bg rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm table-fixed">
<thead> <thead>
<tr className="border-b border-border bg-gray-50"> <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">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 w-28">Tipo</th>
<th className="text-left px-4 py-3 font-medium text-muted">Tiempo</th> <th className="text-left px-4 py-3 font-medium text-muted w-32">Tiempo</th>
<th className="text-right px-4 py-3 font-medium text-muted">Precio</th> <th className="text-right px-4 py-3 font-medium text-muted w-32">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-28">Entregables</th>
<th className="text-center px-4 py-3 font-medium text-muted w-24">Acciones</th> <th className="text-center px-4 py-3 font-medium text-muted w-24">Acciones</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{catServs.map((s) => ( {catServs.map((s) => (
<tr key={s.id} className={clsx("border-b border-border hover:bg-gray-50", !s.activo && "opacity-50")}> <tr key={s.id} className={clsx("border-b border-border hover:bg-gray-50", !s.activo && "opacity-50")}>
<td className="px-4 py-3"> <td className="px-4 py-3 align-top">
<div className="font-medium">{s.nombre}</div> <div className="font-medium flex flex-wrap items-center gap-2 min-w-0">
{s.descripcion && <div className="text-xs text-muted mt-0.5">{s.descripcion}</div>} <span className="break-words">{s.nombre}</span>
{s.nivel && <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700">{s.nivel}</span>}
</div>
{s.descripcion && <div className="text-xs text-muted mt-0.5 break-words">{s.descripcion}</div>}
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3 align-top">
<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")}> <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"} {s.tipoPago === "unico" ? "Unico" : "Mensual"}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-muted text-xs">{s.tiempoEntrega}</td> <td className="px-4 py-3 text-muted text-xs align-top">{s.tiempoEntrega}</td>
<td className="px-4 py-3 text-right font-medium">{formatCurrency(s.precioBase)}</td> <td className="px-4 py-3 text-right font-medium align-top">{formatCurrency(s.precioBase)}</td>
<td className="px-4 py-3 text-center text-muted">{s.entregablesDefault.length}</td> <td className="px-4 py-3 text-center text-muted align-top">{s.entregablesDefault.length}</td>
<td className="px-4 py-3"> <td className="px-4 py-3 align-top">
<div className="flex items-center justify-center gap-1"> <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"> <button onClick={() => setServicioModal(s)} className="p-1.5 text-muted hover:text-primary rounded" title="Editar">
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
@@ -402,7 +404,7 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
return ( return (
<div key={pk.id} className="bg-card-bg rounded-xl border border-border overflow-hidden"> <div key={pk.id} className="bg-card-bg rounded-xl border border-border overflow-hidden">
<div <div
onClick={() => setExpandedPk((prev) => { const n = new Set(prev); n.has(pk.id) ? n.delete(pk.id) : n.add(pk.id); return n; })} onClick={() => setExpandedPk((prev) => { const n = new Set(prev); if (n.has(pk.id)) n.delete(pk.id); else 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" 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" />} {isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
@@ -433,20 +435,23 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq
</div> </div>
</div> </div>
{fase.servicios.length > 0 ? ( {fase.servicios.length > 0 ? (
<table className="w-full text-sm"> <table className="w-full text-sm table-fixed">
<tbody> <tbody>
{fase.servicios.map((s) => ( {fase.servicios.map((s) => (
<tr key={s.id} className="border-t border-border"> <tr key={s.id} className="border-t border-border">
<td className="px-4 py-2"> <td className="px-4 py-2 align-top">
<div className="font-medium">{s.nombre}</div> <div className="font-medium flex flex-wrap items-center gap-2 min-w-0">
<span className="break-words">{s.nombre}</span>
{s.nivel && <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700">{s.nivel}</span>}
</div>
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2 align-top w-28">
<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")}> <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"} {s.tipoPago === "unico" ? "Unico" : "Mensual"}
</span> </span>
</td> </td>
<td className="px-4 py-2 text-right font-medium text-sm">{formatCurrency(s.precioBase)}</td> <td className="px-4 py-2 text-right font-medium text-sm align-top w-32">{formatCurrency(s.precioBase)}</td>
<td className="px-4 py-2 w-10"> <td className="px-4 py-2 w-10 align-top">
<button onClick={() => removeServFromFase(pk.id, fase.id, s.id)} className="text-muted hover:text-red-500"> <button onClick={() => removeServFromFase(pk.id, fase.id, s.id)} className="text-muted hover:text-red-500">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -633,6 +638,7 @@ function ServicioFormModal({
tiempoEntrega: servicio?.tiempoEntrega || "4 - 10 dias", tiempoEntrega: servicio?.tiempoEntrega || "4 - 10 dias",
entregablesDefault: servicio?.entregablesDefault?.join("\n") || "", entregablesDefault: servicio?.entregablesDefault?.join("\n") || "",
variante: servicio?.variante || "", variante: servicio?.variante || "",
nivel: servicio?.nivel || "",
fase: servicio?.fase ?? 1, fase: servicio?.fase ?? 1,
orden: servicio?.orden ?? 0, orden: servicio?.orden ?? 0,
activo: servicio?.activo ?? true, activo: servicio?.activo ?? true,
@@ -647,7 +653,6 @@ function ServicioFormModal({
.split("\n") .split("\n")
.map((e) => e.trim()) .map((e) => e.trim())
.filter(Boolean); .filter(Boolean);
const cat = categorias.find((c) => c.id === form.categoriaId);
await onSave({ await onSave({
nombre: form.nombre.trim(), nombre: form.nombre.trim(),
descripcion: form.descripcion.trim() || null, descripcion: form.descripcion.trim() || null,
@@ -657,6 +662,7 @@ function ServicioFormModal({
tiempoEntrega: form.tiempoEntrega, tiempoEntrega: form.tiempoEntrega,
entregablesDefault: entregables, entregablesDefault: entregables,
variante: form.variante.trim() || null, variante: form.variante.trim() || null,
nivel: form.nivel.trim() || null,
fase: form.fase, fase: form.fase,
orden: form.orden, orden: form.orden,
activo: form.activo, activo: form.activo,
@@ -720,7 +726,17 @@ function ServicioFormModal({
<label className="block text-sm font-medium mb-1">Entregables (uno por linea)</label> <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" /> <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>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Nivel</label>
<select value={form.nivel} onChange={(e) => update("nivel", 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 nivel</option>
<option value="Emprendedor">Emprendedor</option>
<option value="PYME">PYME</option>
<option value="Empresario">Empresario</option>
<option value="Corporativo">Corporativo</option>
</select>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">Variante</label> <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" /> <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" />
+1
View File
@@ -39,6 +39,7 @@ export default async function CatalogoPage() {
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria", categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
categoriaColor: s.categoriaRel?.color || "#6b7280", categoriaColor: s.categoriaRel?.color || "#6b7280",
variante: s.variante, variante: s.variante,
nivel: s.nivel,
activo: s.activo, activo: s.activo,
orden: s.orden, orden: s.orden,
}))} }))}
+8 -7
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { NuevaCotizacionClienteButton } from "./NuevaCotizacionClienteButton"; import { NuevaCotizacionClienteButton } from "./NuevaCotizacionClienteButton";
@@ -10,21 +10,22 @@ interface Cliente {
empresa: string | null; empresa: string | null;
email: string | null; email: string | null;
telefono: string | null; telefono: string | null;
cotizaciones: { id: string }[]; _count: { cotizaciones: number };
} }
export function ClientesList({ clientes }: { clientes: Cliente[] }) { export function ClientesList({ clientes }: { clientes: Cliente[] }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filtered = clientes.filter((c) => { const filtered = useMemo(() => {
if (!search) return true; if (!search) return clientes;
const q = search.toLowerCase(); const q = search.toLowerCase();
return ( return clientes.filter(
(c) =>
c.nombre.toLowerCase().includes(q) || c.nombre.toLowerCase().includes(q) ||
(c.empresa || "").toLowerCase().includes(q) || (c.empresa || "").toLowerCase().includes(q) ||
(c.email || "").toLowerCase().includes(q) (c.email || "").toLowerCase().includes(q)
); );
}); }, [clientes, search]);
return ( return (
<> <>
@@ -80,7 +81,7 @@ export function ClientesList({ clientes }: { clientes: Cliente[] }) {
<td className="px-5 py-3 text-muted">{c.telefono || "-"}</td> <td className="px-5 py-3 text-muted">{c.telefono || "-"}</td>
<td className="px-5 py-3 text-center"> <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"> <span className="inline-block px-2 py-0.5 bg-primary-light text-primary rounded text-xs font-medium">
{c.cotizaciones.length} {c._count.cotizaciones}
</span> </span>
</td> </td>
<td className="px-5 py-3 text-center"> <td className="px-5 py-3 text-center">
+9 -2
View File
@@ -5,7 +5,14 @@ export const dynamic = "force-dynamic";
export default async function ClientesPage() { export default async function ClientesPage() {
const clientes = await prisma.cliente.findMany({ const clientes = await prisma.cliente.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { cotizaciones: { select: { id: true } } }, select: {
id: true,
nombre: true,
empresa: true,
email: true,
telefono: true,
_count: { select: { cotizaciones: true } },
},
}); });
return ( return (
@@ -44,7 +51,7 @@ function ClientesListWrapper({
empresa: string | null; empresa: string | null;
email: string | null; email: string | null;
telefono: string | null; telefono: string | null;
cotizaciones: { id: string }[]; _count: { cotizaciones: number };
}[]; }[];
}) { }) {
return <ClientesList clientes={clientes} />; return <ClientesList clientes={clientes} />;
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Save, Upload, Trash2, Loader2, Image } from "lucide-react"; import { Save, Upload, Trash2, Loader2, Image as ImageIcon } from "lucide-react";
const SECTIONS = [ const SECTIONS = [
{ {
@@ -142,13 +142,14 @@ export function ConfiguracionClient({ initial }: { initial: Record<string, strin
<div className="flex items-start gap-4"> <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"> <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 ? ( {config.logo_base64 ? (
// eslint-disable-next-line @next/next/no-img-element -- logo es un data-URL base64, next/image no aplica
<img <img
src={config.logo_base64.includes(":") ? `data:${config.logo_base64.replace(":", ";base64,")}` : `data:image/png;base64,${config.logo_base64}`} src={config.logo_base64.includes(":") ? `data:${config.logo_base64.replace(":", ";base64,")}` : `data:image/png;base64,${config.logo_base64}`}
alt="Logo" alt="Logo"
className="w-full h-full object-contain p-1" className="w-full h-full object-contain p-1"
/> />
) : ( ) : (
<Image className="w-8 h-8 text-muted" /> <ImageIcon className="w-8 h-8 text-muted" />
)} )}
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Pencil, Search } from "lucide-react"; import { Pencil, Search } from "lucide-react";
import { formatDate, formatCurrency } from "@/lib/calculators"; import { formatDate, formatCurrency } from "@/lib/calculators";
@@ -24,17 +24,18 @@ export function CotizacionesList({
}) { }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filtered = cotizaciones.filter((cot) => { const filtered = useMemo(() => {
if (!search) return true; if (!search) return cotizaciones;
const q = search.toLowerCase(); const q = search.toLowerCase();
return ( return cotizaciones.filter(
(cot) =>
cot.numero.toLowerCase().includes(q) || cot.numero.toLowerCase().includes(q) ||
cot.cliente.nombre.toLowerCase().includes(q) || cot.cliente.nombre.toLowerCase().includes(q) ||
(cot.cliente.empresa || "").toLowerCase().includes(q) || (cot.cliente.empresa || "").toLowerCase().includes(q) ||
cot.asesor.name.toLowerCase().includes(q) || cot.asesor.name.toLowerCase().includes(q) ||
cot.estado.toLowerCase().includes(q) cot.estado.toLowerCase().includes(q)
); );
}); }, [cotizaciones, search]);
return ( return (
<> <>
@@ -46,14 +46,23 @@ export default async function EditarCotizacionPage({
incluirFinanciamiento: cot.incluirFinanciamiento, incluirFinanciamiento: cot.incluirFinanciamiento,
observaciones: cot.observaciones || "", observaciones: cot.observaciones || "",
planBucefaloNivel: cot.planBucefalo?.nivel || null, planBucefaloNivel: cot.planBucefalo?.nivel || null,
esDoble: cot.esDoble,
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
servicios: cot.servicios.map((s) => ({ servicios: cot.servicios.map((s) => ({
catalogoId: s.servicioCatalogoId, catalogoId: s.servicioCatalogoId ?? `custom-${s.id}`,
nombre: s.servicioCatalogo?.nombre || "Servicio", nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
fase: s.fase, fase: s.fase,
tipoPago: s.tipoPago, tipoPago: s.tipoPago,
precio: s.precio, precio: s.precio,
tiempoEntrega: s.tiempoEntrega, tiempoEntrega: s.tiempoEntrega,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [], entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
esPersonalizado: s.esPersonalizado,
horas: s.horas ?? undefined,
tarifaHora: s.tarifaHora ?? undefined,
modeloCobro: s.modeloCobro ?? undefined,
montoMinimo: s.montoMinimo ?? undefined,
horasIncluidas: s.horasIncluidas ?? undefined,
opcion: s.opcion ?? undefined,
})), })),
estado: cot.estado, estado: cot.estado,
}; };
+63 -4
View File
@@ -1,9 +1,9 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { formatDate, formatCurrency, IVA_RATE } from "@/lib/calculators"; import { formatDate, formatCurrency, calcularTotalesOpcion, IVA_RATE, type MetaOpcion } from "@/lib/calculators";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft, Pencil } from "lucide-react"; import { ArrowLeft, Pencil } from "lucide-react";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ExportExcelButtonSaved, ExportPDFButtonSaved } from "@/components/ExportButtons"; import { ExportExcelButtonSaved, ExportPDFButtonSaved, PreviewPDFButtonSaved } from "@/components/ExportButtons";
import { DeleteCotizacionButton } from "./DeleteButton"; import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons"; import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables"; import { PreciosEditables } from "./PreciosEditables";
@@ -32,7 +32,7 @@ export default async function CotizacionDetailPage({
.filter((s) => s.tipoPago === "unico" && s.seleccionado) .filter((s) => s.tipoPago === "unico" && s.seleccionado)
.map((s) => ({ .map((s) => ({
id: s.id, id: s.id,
nombre: s.servicioCatalogo?.nombre || "Servicio", nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
precio: s.precio, precio: s.precio,
})); }));
@@ -40,10 +40,18 @@ export default async function CotizacionDetailPage({
.filter((s) => s.tipoPago === "mensual" && s.seleccionado) .filter((s) => s.tipoPago === "mensual" && s.seleccionado)
.map((s) => ({ .map((s) => ({
id: s.id, id: s.id,
nombre: s.servicioCatalogo?.nombre || "Servicio", nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
precio: s.precio, precio: s.precio,
})); }));
const opcionesMeta = (cot.opcionesMetadata as { "1"?: MetaOpcion; "2"?: MetaOpcion } | null) ?? {};
const totalesOpcion = cot.esDoble
? {
"1": calcularTotalesOpcion(cot.servicios, "1"),
"2": calcularTotalesOpcion(cot.servicios, "2"),
}
: null;
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -62,6 +70,7 @@ export default async function CotizacionDetailPage({
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PreviewPDFButtonSaved cotizacionId={cot.id} />
<ExportPDFButtonSaved cotizacionId={cot.id} /> <ExportPDFButtonSaved cotizacionId={cot.id} />
<ExportExcelButtonSaved cotizacionId={cot.id} /> <ExportExcelButtonSaved cotizacionId={cot.id} />
<Link <Link
@@ -115,6 +124,56 @@ export default async function CotizacionDetailPage({
</div> </div>
</div> </div>
{cot.esDoble && totalesOpcion && (
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<h2 className="font-semibold text-lg mb-4">
Doble propuesta comparativa
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(["1", "2"] as const).map((op) => {
const t = totalesOpcion[op];
const meta = opcionesMeta[op];
return (
<div key={op} className="border border-primary/30 rounded-lg p-4 bg-primary-light/10">
<h3 className="font-semibold text-primary">
Opcion {op}
{meta?.titulo ? `: ${meta.titulo}` : ""}
</h3>
{meta?.descripcion && (
<p className="text-sm text-muted mt-1 whitespace-pre-wrap">{meta.descripcion}</p>
)}
{meta?.noIncluye && (
<p className="text-xs text-muted mt-2">
<span className="font-medium">No incluye:</span>{" "}
<span className="whitespace-pre-wrap">{meta.noIncluye}</span>
</p>
)}
<div className="mt-3 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span>Total unico (c/IVA)</span>
<span className="font-medium">{formatCurrency(t.totalUnico * (1 + IVA_RATE))}</span>
</div>
<div className="flex justify-between">
<span>Total mensual (c/IVA)</span>
<span className="font-medium">{formatCurrency(t.totalMensual * (1 + IVA_RATE))}</span>
</div>
{t.horas > 0 && (
<div className="flex justify-between text-muted text-xs">
<span>Horas estimadas</span>
<span>{t.horas} h</span>
</div>
)}
</div>
</div>
);
})}
</div>
<p className="text-xs text-muted mt-3">
Las partidas marcadas <b>Ambas</b> suman en las dos opciones. Edita la asignacion de cada partida desde <b>Editar</b>.
</p>
</div>
)}
<PreciosEditables <PreciosEditables
cotizacionId={cot.id} cotizacionId={cot.id}
serviciosUnicos={serviciosUnicos} serviciosUnicos={serviciosUnicos}
+1 -2
View File
@@ -4,14 +4,13 @@ import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function NuevaCotizacionPage() { export default async function NuevaCotizacionPage() {
const [servicios, asesores, clientes, paquetes] = await Promise.all([ const [servicios, asesores, paquetes] = await Promise.all([
prisma.servicioCatalogo.findMany({ prisma.servicioCatalogo.findMany({
where: { activo: true }, where: { activo: true },
orderBy: { orden: "asc" }, orderBy: { orden: "asc" },
include: { categoriaRel: true }, include: { categoriaRel: true },
}), }),
prisma.user.findMany({ orderBy: { name: "asc" } }), prisma.user.findMany({ orderBy: { name: "asc" } }),
prisma.cliente.findMany({ orderBy: { nombre: "asc" } }),
prisma.paquete.findMany({ prisma.paquete.findMany({
where: { activo: true }, where: { activo: true },
include: { include: {
+11 -1
View File
@@ -8,7 +8,17 @@ export const dynamic = "force-dynamic";
export default async function CotizacionesPage() { export default async function CotizacionesPage() {
const cotizaciones = await prisma.cotizacion.findMany({ const cotizaciones = await prisma.cotizacion.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { cliente: true, asesor: true, servicios: true }, select: {
id: true,
numero: true,
estado: true,
fecha: true,
cliente: { select: { nombre: true, empresa: true } },
asesor: { select: { name: true } },
servicios: {
select: { tipoPago: true, seleccionado: true, precio: true },
},
},
}); });
return ( return (
+10 -1
View File
@@ -17,7 +17,16 @@ export default async function DashboardPage() {
prisma.cotizacion.findMany({ prisma.cotizacion.findMany({
take: 10, take: 10,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { cliente: true, asesor: true, servicios: true }, select: {
id: true,
numero: true,
fecha: true,
estado: true,
cliente: { select: { nombre: true, empresa: true } },
servicios: {
select: { tipoPago: true, seleccionado: true, precio: true },
},
},
}), }),
prisma.cliente.count(), prisma.cliente.count(),
prisma.servicioCatalogo.count({ where: { activo: true } }), prisma.servicioCatalogo.count({ where: { activo: true } }),
+1
View File
@@ -19,6 +19,7 @@ export async function PUT(
tiempoEntrega: body.tiempoEntrega, tiempoEntrega: body.tiempoEntrega,
entregablesDefault: body.entregablesDefault || [], entregablesDefault: body.entregablesDefault || [],
variante: body.variante || null, variante: body.variante || null,
nivel: body.nivel || null,
activo: body.activo !== false, activo: body.activo !== false,
orden: body.orden, orden: body.orden,
categoriaId: body.categoriaId || null, categoriaId: body.categoriaId || null,
+1
View File
@@ -35,6 +35,7 @@ export async function POST(request: NextRequest) {
entregablesDefault: parsed.data.entregablesDefault, entregablesDefault: parsed.data.entregablesDefault,
categoriaId: parsed.data.categoriaId, categoriaId: parsed.data.categoriaId,
variante: parsed.data.variante, variante: parsed.data.variante,
nivel: parsed.data.nivel ?? null,
activo: true, activo: true,
orden: parsed.data.orden, orden: parsed.data.orden,
}, },
+43 -20
View File
@@ -74,6 +74,8 @@ export async function PUT(
esquemaPago, esquemaPago,
incluirBonos, incluirBonos,
incluirFinanciamiento, incluirFinanciamiento,
esDoble,
opciones,
observaciones, observaciones,
cliente, cliente,
servicios, servicios,
@@ -86,15 +88,15 @@ export async function PUT(
return NextResponse.json({ error: "No encontrada" }, { status: 404 }); return NextResponse.json({ error: "No encontrada" }, { status: 404 });
} }
let clienteIdFinal: string | undefined;
if (cliente) { if (cliente) {
const existingCliente = await prisma.cliente.findFirst({ const existingCliente = await prisma.cliente.findFirst({
where: { nombre: cliente.nombre, empresa: cliente.empresa || null }, where: { nombre: cliente.nombre, empresa: cliente.empresa || null },
}); });
let clienteId = existing.clienteId;
if (existingCliente) { if (existingCliente) {
clienteId = existingCliente.id; clienteIdFinal = existingCliente.id;
await prisma.cliente.update({ await prisma.cliente.update({
where: { id: clienteId }, where: { id: clienteIdFinal },
data: { data: {
email: cliente.email || existingCliente.email, email: cliente.email || existingCliente.email,
telefono: cliente.telefono || existingCliente.telefono, telefono: cliente.telefono || existingCliente.telefono,
@@ -109,18 +111,16 @@ export async function PUT(
telefono: cliente.telefono || null, telefono: cliente.telefono || null,
}, },
}); });
clienteId = newCliente.id; clienteIdFinal = newCliente.id;
}
} }
await prisma.cotizacion.update({ const esDobleFinal = esDoble ?? existing.esDoble;
where: { id },
data: { clienteId },
});
}
await prisma.cotizacion.update({ await prisma.cotizacion.update({
where: { id }, where: { id },
data: { data: {
...(clienteIdFinal && { clienteId: clienteIdFinal }),
...(fecha && { fecha: new Date(fecha) }), ...(fecha && { fecha: new Date(fecha) }),
...(vigencia && { vigencia: new Date(vigencia) }), ...(vigencia && { vigencia: new Date(vigencia) }),
...(moneda && { moneda }), ...(moneda && { moneda }),
@@ -129,35 +129,58 @@ export async function PUT(
...(esquemaPago && { esquemaPago }), ...(esquemaPago && { esquemaPago }),
...(incluirBonos !== undefined && { incluirBonos }), ...(incluirBonos !== undefined && { incluirBonos }),
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }), ...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
...(esDoble !== undefined && { esDoble }),
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
...(observaciones !== undefined && { observaciones }), ...(observaciones !== undefined && { observaciones }),
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }), ...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
}, },
}); });
if (servicios && Array.isArray(servicios)) { if (servicios && Array.isArray(servicios)) {
// El servicio Bucéfalo del catálogo se resuelve una sola vez, no por partida.
const necesitaBucefalo = servicios.some(
(s) => !s.esPersonalizado && s.catalogoId?.startsWith("bucefalo-")
);
const bucefaloCatalogoId = necesitaBucefalo
? (await prisma.servicioCatalogo.findFirst({
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" },
select: { id: true },
}))?.id ?? null
: null;
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.servicioCotizado.deleteMany({ where: { cotizacionId: id } }); await tx.servicioCotizado.deleteMany({ where: { cotizacionId: id } });
for (const serv of servicios) { await tx.servicioCotizado.createMany({
const catalogoId = serv.catalogoId?.startsWith("bucefalo-") data: servicios.map((serv) => {
? (await tx.servicioCatalogo.findFirst({ let catalogoId: string | null;
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" }, if (serv.esPersonalizado) {
}))?.id || serv.catalogoId catalogoId = null;
: serv.catalogoId; } else if (serv.catalogoId?.startsWith("bucefalo-")) {
catalogoId = bucefaloCatalogoId;
await tx.servicioCotizado.create({ } else {
data: { catalogoId = serv.catalogoId;
}
return {
cotizacionId: id, cotizacionId: id,
servicioCatalogoId: catalogoId, servicioCatalogoId: catalogoId,
nombre: serv.esPersonalizado ? serv.nombre : null,
esPersonalizado: serv.esPersonalizado ?? false,
horas: serv.esPersonalizado ? serv.horas ?? null : null,
tarifaHora: serv.esPersonalizado ? serv.tarifaHora ?? null : null,
modeloCobro: serv.modeloCobro ?? (serv.esPersonalizado ? "horas" : "fijo"),
montoMinimo: serv.esPersonalizado ? serv.montoMinimo ?? null : null,
horasIncluidas: serv.esPersonalizado ? serv.horasIncluidas ?? null : null,
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
fase: serv.fase, fase: serv.fase,
tipoPago: serv.tipoPago, tipoPago: serv.tipoPago,
precio: serv.precio, precio: serv.precio,
tiempoEntrega: serv.tiempoEntrega, tiempoEntrega: serv.tiempoEntrega,
entregables: serv.entregables, entregables: serv.entregables,
seleccionado: true, seleccionado: true,
}, };
}),
}); });
}
}); });
} }
+37 -10
View File
@@ -22,6 +22,8 @@ export async function POST(request: NextRequest) {
esquemaPago, esquemaPago,
incluirBonos, incluirBonos,
incluirFinanciamiento, incluirFinanciamiento,
esDoble,
opciones,
observaciones, observaciones,
cliente, cliente,
asesorId, asesorId,
@@ -49,6 +51,18 @@ export async function POST(request: NextRequest) {
const clienteIdFinal = clienteRecord.id; const clienteIdFinal = clienteRecord.id;
// El servicio Bucéfalo del catálogo es el mismo para todas las partidas:
// se resuelve una sola vez fuera del loop de inserción.
const necesitaBucefalo = servicios.some(
(s) => !s.esPersonalizado && s.catalogoId.startsWith("bucefalo-")
);
const bucefaloCatalogoId = necesitaBucefalo
? (await prisma.servicioCatalogo.findFirst({
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" },
select: { id: true },
}))?.id ?? null
: null;
const cotizacion = await prisma.$transaction(async (tx) => { const cotizacion = await prisma.$transaction(async (tx) => {
const cot = await tx.cotizacion.create({ const cot = await tx.cotizacion.create({
data: { data: {
@@ -61,6 +75,8 @@ export async function POST(request: NextRequest) {
esquemaPago, esquemaPago,
incluirBonos, incluirBonos,
incluirFinanciamiento, incluirFinanciamiento,
esDoble: esDoble ?? false,
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
observaciones: observaciones || null, observaciones: observaciones || null,
clienteId: clienteIdFinal, clienteId: clienteIdFinal,
asesorId, asesorId,
@@ -68,25 +84,36 @@ export async function POST(request: NextRequest) {
}, },
}); });
for (const servicio of servicios) { await tx.servicioCotizado.createMany({
const catalogoId = servicio.catalogoId.startsWith("bucefalo-") data: servicios.map((servicio) => {
? (await tx.servicioCatalogo.findFirst({ let catalogoId: string | null;
where: { categoriaRel: { nombre: "CRM" }, tipoPago: "mensual" }, if (servicio.esPersonalizado) {
}))?.id || servicio.catalogoId catalogoId = null;
: servicio.catalogoId; } else if (servicio.catalogoId.startsWith("bucefalo-")) {
await tx.servicioCotizado.create({ catalogoId = bucefaloCatalogoId;
data: { } else {
catalogoId = servicio.catalogoId;
}
return {
cotizacionId: cot.id, cotizacionId: cot.id,
servicioCatalogoId: catalogoId, servicioCatalogoId: catalogoId,
nombre: servicio.esPersonalizado ? servicio.nombre : null,
esPersonalizado: servicio.esPersonalizado ?? false,
horas: servicio.esPersonalizado ? servicio.horas ?? null : null,
tarifaHora: servicio.esPersonalizado ? servicio.tarifaHora ?? null : null,
modeloCobro: servicio.modeloCobro ?? (servicio.esPersonalizado ? "horas" : "fijo"),
montoMinimo: servicio.esPersonalizado ? servicio.montoMinimo ?? null : null,
horasIncluidas: servicio.esPersonalizado ? servicio.horasIncluidas ?? null : null,
opcion: esDoble ? servicio.opcion ?? "ambas" : null,
fase: servicio.fase, fase: servicio.fase,
tipoPago: servicio.tipoPago, tipoPago: servicio.tipoPago,
precio: servicio.precio, precio: servicio.precio,
tiempoEntrega: servicio.tiempoEntrega, tiempoEntrega: servicio.tiempoEntrega,
entregables: servicio.entregables, entregables: servicio.entregables,
seleccionado: true, seleccionado: true,
}, };
}),
}); });
}
if (planBucefalo) { if (planBucefalo) {
await tx.planBucefaloCotizacion.create({ await tx.planBucefaloCotizacion.create({
+46 -321
View File
@@ -1,24 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import ExcelJS from "exceljs";
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers"; import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
import { sanitizeFilename } from "@/lib/calculators"; import { sanitizeFilename } from "@/lib/calculators";
import { buildCotizacionExcel, type ExcelData } from "@/lib/excel-builder";
function hexToArgb(hex: string): string {
const h = hex.replace("#", "");
return "FF" + h.toUpperCase();
}
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
const bc: Partial<ExcelJS.Border> = { style: "thin", color: { argb: color || "FFD1D5DB" } };
cell.border = { top: bc, left: bc, bottom: bc, right: bc };
}
function fillRow(ws: ExcelJS.Worksheet, row: number, startCol: string, endCol: string, argb: string) {
for (let c = ws.getColumn(startCol).number; c <= ws.getColumn(endCol).number; c++) {
ws.getCell(row, c).fill = { type: "pattern", pattern: "solid", fgColor: { argb } };
}
}
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
@@ -27,7 +11,8 @@ export async function GET(
try { try {
const { id } = await params; const { id } = await params;
const cot = await prisma.cotizacion.findUnique({ const [cot, branding, bancaria] = await Promise.all([
prisma.cotizacion.findUnique({
where: { id }, where: { id },
include: { include: {
cliente: true, cliente: true,
@@ -35,317 +20,57 @@ export async function GET(
servicios: { include: { servicioCatalogo: true } }, servicios: { include: { servicioCatalogo: true } },
planBucefalo: true, planBucefalo: true,
}, },
}); }),
getConfigBranding(),
getConfigBancaria(),
]);
if (!cot) { if (!cot) {
return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 }); return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
} }
const empresa = cot.cliente.empresa || cot.cliente.nombre; const empresa = cot.cliente.empresa || cot.cliente.nombre;
const branding = await getConfigBranding();
const bancaria = await getConfigBancaria();
const configColor = branding.colorPrimario || "#2563eb";
const PRIMARY = hexToArgb(configColor);
const PRIMARY_LIGHT = hexToArgb(configColor + "18");
const SECONDARY = hexToArgb(branding.colorSecundario || "#1e293b");
const WHITE = "FFFFFFFF";
const LIGHT_BG = "FFF8FAFC";
const MUTED = "FF64748B";
const BORDER_COLOR = "FFE2E8F0";
const workbook = new ExcelJS.Workbook(); const data: ExcelData = {
workbook.creator = "Cotizador E3"; razonSocial: bancaria.razon_social || "Cotizador E3",
workbook.created = new Date(); domicilioFiscal: bancaria.domicilio_fiscal || undefined,
numero: cot.numero,
clienteNombre: cot.cliente.nombre,
clienteEmpresa: cot.cliente.empresa || "",
asesorNombre: cot.asesor.name,
fecha: cot.fecha,
vigencia: cot.vigencia,
moneda: cot.moneda,
tipoCambio: cot.tipoCambio,
proyecto: cot.proyecto,
esquemaPago: cot.esquemaPago,
servicios: cot.servicios
.filter((s) => s.seleccionado)
.map((s) => ({
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
fase: s.fase,
tipoPago: s.tipoPago,
precio: s.precio,
tiempoEntrega: s.tiempoEntrega,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
modeloCobro: s.modeloCobro,
esPersonalizado: s.esPersonalizado,
horas: s.horas,
tarifaHora: s.tarifaHora,
montoMinimo: s.montoMinimo,
horasIncluidas: s.horasIncluidas,
opcion: s.opcion,
})),
esDoble: cot.esDoble,
opcionesMetadata: cot.opcionesMetadata as never,
planBucefaloNivel: cot.planBucefalo?.nivel ?? null,
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
colorPrimario: branding.colorPrimario || "#2563eb",
colorSecundario: branding.colorSecundario || "#1e293b",
};
const headerFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: WHITE } }; const buffer = await buildCotizacionExcel(data);
const labelFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: MUTED } }; return new NextResponse(new Uint8Array(buffer), {
const valueFont: Partial<ExcelJS.Font> = { size: 10, name: "Calibri" };
const boldFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri" };
const totalFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri" };
const smallFont: Partial<ExcelJS.Font> = { italic: true, size: 9, name: "Calibri", color: { argb: MUTED } };
const faseFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri", color: { argb: PRIMARY } };
const ws = workbook.addWorksheet("HOJA RESUMEN");
ws.columns = [
{ width: 3 },
{ width: 22 }, { width: 22 }, { width: 16 },
{ width: 38 }, { width: 5 },
{ width: 20 }, { width: 5 },
{ width: 20 }, { width: 5 },
{ width: 18 },
];
ws.views = [{ showGridLines: false }];
// ── HEADER BANNER ──
ws.mergeCells("B2:K2");
ws.getCell("B2").value = bancaria.razon_social || "Cotizador E3";
ws.getCell("B2").font = { bold: true, size: 16, name: "Calibri", color: { argb: WHITE } };
ws.getCell("B2").alignment = { vertical: "middle" };
fillRow(ws, 2, "A", "K", PRIMARY);
ws.mergeCells("B3:K3");
ws.getCell("B3").value = cot.numero;
ws.getCell("B3").font = { bold: true, size: 11, name: "Calibri", color: { argb: WHITE } };
ws.getCell("B3").alignment = { vertical: "middle" };
fillRow(ws, 3, "A", "K", SECONDARY);
// ── CLIENTE / PROYECTO ──
const infoStart = 5;
const infoLabels: [string, string, string, string][] = [
["B", "En atencion a:", "C", cot.cliente.nombre],
["E", "Empresa:", "F", cot.cliente.empresa || "—"],
["B", "Proyecto:", "C", cot.proyecto],
["E", "Moneda:", "F", cot.moneda],
["B", "Asesor:", "C", cot.asesor.name],
["E", "Tipo de Cambio:", "F", cot.tipoCambio],
["B", "Fecha:", "C", ""],
["E", "Esquema de Pago:", "F", cot.esquemaPago],
];
for (let i = 0; i < infoLabels.length; i += 2) {
const r = infoStart + (i / 2);
const [lc1, lv1, vc1, vv1] = infoLabels[i];
const [lc2, lv2, vc2, vv2] = infoLabels[i + 1];
ws.getCell(`${lc1}${r}`).value = lv1;
ws.getCell(`${lc1}${r}`).font = labelFont;
ws.getCell(`${vc1}${r}`).value = vv1;
ws.getCell(`${vc1}${r}`).font = valueFont;
ws.getCell(`${lc2}${r}`).value = lv2;
ws.getCell(`${lc2}${r}`).font = labelFont;
ws.getCell(`${vc2}${r}`).value = vv2;
ws.getCell(`${vc2}${r}`).font = valueFont;
}
const fechaCell = ws.getCell(`C${infoStart + 6}`);
fechaCell.value = cot.fecha;
fechaCell.numFmt = "DD/MM/YYYY";
fechaCell.font = valueFont;
const vigenciaCell = ws.getCell(`C${infoStart + 7}`);
ws.getCell(`B${infoStart + 7}`).value = "Vigencia:";
ws.getCell(`B${infoStart + 7}`).font = labelFont;
vigenciaCell.value = cot.vigencia;
vigenciaCell.numFmt = "DD/MM/YYYY";
vigenciaCell.font = valueFont;
// ── TABLA SERVICIOS ──
const tableStart = infoStart + 9;
const cols = ["B", "C", "D", "E", "K"];
const headers = ["Fase", "Tipo de Pago", "Servicio", "", "Precio"];
ws.mergeCells(`D${tableStart}:J${tableStart}`);
for (let i = 0; i < cols.length; i++) {
const cell = ws.getCell(`${cols[i]}${tableStart}`);
cell.value = headers[i];
cell.font = headerFont;
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
cell.alignment = { horizontal: i === cols.length - 1 ? "right" : "left", vertical: "middle" };
applyThinBorder(cell, PRIMARY);
}
ws.getRow(tableStart).height = 24;
const fases: Record<number, string> = { 0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3" };
const serviciosUnicos = cot.servicios.filter(s => s.tipoPago === "unico" && s.seleccionado);
const serviciosMensuales = cot.servicios.filter(s => s.tipoPago === "mensual" && s.seleccionado);
let row = tableStart + 1;
let currentFase = -1;
for (const serv of [...serviciosUnicos, ...serviciosMensuales]) {
if (serv.fase !== currentFase) {
currentFase = serv.fase;
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = fases[serv.fase] || `FASE ${serv.fase}`;
ws.getCell(`B${row}`).font = faseFont;
fillRow(ws, row, "B", "K", PRIMARY_LIGHT);
row++;
}
const isAlt = row % 2 === 0;
const rowBg = isAlt ? LIGHT_BG : WHITE;
ws.getCell(`B${row}`).value = "";
ws.getCell(`C${row}`).value = serv.tipoPago === "unico" ? "Unico" : "Mensual";
ws.getCell(`C${row}`).font = valueFont;
ws.mergeCells(`D${row}:J${row}`);
ws.getCell(`D${row}`).value = serv.servicioCatalogo?.nombre || "Servicio";
ws.getCell(`D${row}`).font = valueFont;
ws.getCell(`K${row}`).value = serv.precio;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = valueFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: rowBg } };
applyThinBorder(ws.getCell(`${c}${row}`), BORDER_COLOR);
}
row++;
}
// ── TOTALES ──
row++;
const totalUnico = serviciosUnicos.reduce((s, x) => s + x.precio, 0);
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "Total Pago Unico";
ws.getCell(`B${row}`).font = totalFont;
ws.getCell(`K${row}`).value = totalUnico;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = totalFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row++;
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "(+ IVA)";
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
row++;
const totalMensual = serviciosMensuales.reduce((s, x) => s + x.precio, 0);
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "Total Pago Mensual";
ws.getCell(`B${row}`).font = totalFont;
ws.getCell(`K${row}`).value = totalMensual;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = totalFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row++;
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "(+ IVA)";
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
row += 2;
if (cot.planBucefalo) {
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = `CRM Bucefalo - ${cot.planBucefalo.nivel.charAt(0).toUpperCase() + cot.planBucefalo.nivel.slice(1)}`;
ws.getCell(`B${row}`).font = boldFont;
ws.getCell(`K${row}`).value = cot.planBucefalo.precio;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = boldFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row += 2;
}
// ── NOTA AL PIE ──
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles.";
ws.getCell(`B${row}`).font = smallFont;
// ── HOJAS DETALLADAS POR SERVICIO ──
for (const serv of cot.servicios.filter(s => s.seleccionado)) {
const nombre = serv.servicioCatalogo?.nombre || "Servicio";
const safeName = nombre.substring(0, 31).replace(/[/\\*?[\]:]/g, "");
const dw = workbook.addWorksheet(safeName);
dw.columns = [
{ width: 3 },
{ width: 35 }, { width: 5 }, { width: 15 },
{ width: 5 },
{ width: 35 }, { width: 5 }, { width: 15 },
{ width: 5 }, { width: 18 },
];
dw.views = [{ showGridLines: false }];
// Banner
dw.mergeCells("B1:J1");
dw.getCell("B1").value = `${fases[serv.fase] || "FASE " + serv.fase}${nombre}`;
dw.getCell("B1").font = { bold: true, size: 14, name: "Calibri", color: { argb: WHITE } };
dw.getCell("B1").alignment = { vertical: "middle" };
fillRow(dw, 1, "A", "J", PRIMARY);
dw.getRow(1).height = 30;
// Info
let r = 3;
dw.getCell(`B${r}`).value = "Cliente:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = cot.cliente.empresa || cot.cliente.nombre;
dw.getCell(`C${r}`).font = boldFont;
dw.getCell(`F${r}`).value = "No. Cotizacion:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = cot.numero;
dw.getCell(`G${r}`).font = boldFont;
r += 2;
// Servicio header
dw.getCell(`B${r}`).value = "Servicio:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = nombre;
dw.getCell(`C${r}`).font = { ...boldFont, size: 12, color: { argb: PRIMARY } };
dw.getCell(`F${r}`).value = "Tiempo de Entrega:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = serv.tiempoEntrega;
dw.getCell(`G${r}`).font = valueFont;
r += 2;
// Tabla entregables
const tableCols = ["B", "C"];
const thCols = ["B", "I"];
for (const c of thCols) {
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
dw.getCell(`${c}${r}`).font = headerFont;
dw.getCell(`${c}${r}`).border = { bottom: { style: "thin", color: { argb: PRIMARY } } };
}
dw.getCell(`B${r}`).value = "Entregable";
dw.getCell(`I${r}`).value = "";
r++;
const entregables = Array.isArray(serv.entregables) ? serv.entregables as string[] : [];
for (let i = 0; i < entregables.length; i++) {
const isAlt = i % 2 === 1;
dw.getCell(`B${i + r}`).value = `${i + 1}. ${entregables[i]}`;
dw.getCell(`B${i + r}`).font = valueFont;
const bg = isAlt ? LIGHT_BG : WHITE;
for (const c of tableCols) {
dw.getCell(`${c}${i + r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: bg } };
}
applyThinBorder(dw.getCell(`B${i + r}`), BORDER_COLOR);
}
r += entregables.length + 1;
// Precio
for (const c of ["B", "I"]) {
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
applyThinBorder(dw.getCell(`${c}${r}`), PRIMARY);
}
dw.getCell(`B${r}`).value = `Total Pago ${serv.tipoPago === "unico" ? "Unico" : "Mensual"}`;
dw.getCell(`B${r}`).font = totalFont;
dw.getCell(`I${r}`).value = serv.precio;
dw.getCell(`I${r}`).numFmt = '$#,##0.00';
dw.getCell(`I${r}`).font = totalFont;
dw.getCell(`I${r}`).alignment = { horizontal: "right" };
r++;
dw.getCell(`B${r}`).value = "(+ IVA)";
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
r += 2;
// Footer
dw.mergeCells(`B${r}:J${r}`);
dw.getCell(`B${r}`).value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA.";
dw.getCell(`B${r}`).font = smallFont;
r += 2;
dw.getCell(`B${r}`).value = bancaria.razon_social || "Cotizador E3";
dw.getCell(`B${r}`).font = { ...boldFont, color: { argb: PRIMARY } };
if (bancaria.domicilio_fiscal) {
r++;
dw.mergeCells(`B${r}:J${r}`);
dw.getCell(`B${r}`).value = bancaria.domicilio_fiscal;
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
}
}
const buffer = await workbook.xlsx.writeBuffer();
return new NextResponse(buffer, {
headers: { headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}.xlsx"`, "Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}.xlsx"`,
+51 -304
View File
@@ -1,23 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs"; import { calcularVigencia, sanitizeFilename } from "@/lib/calculators";
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers"; import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
import { buildCotizacionExcel, type ExcelData } from "@/lib/excel-builder";
function hexToArgb(hex: string): string {
const h = hex.replace("#", "");
return "FF" + h.toUpperCase();
}
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
const bc: Partial<ExcelJS.Border> = { style: "thin", color: { argb: color || "FFD1D5DB" } };
cell.border = { top: bc, left: bc, bottom: bc, right: bc };
}
function fillRow(ws: ExcelJS.Worksheet, row: number, startCol: string, endCol: string, argb: string) {
for (let c = ws.getColumn(startCol).number; c <= ws.getColumn(endCol).number; c++) {
ws.getCell(row, c).fill = { type: "pattern", pattern: "solid", fgColor: { argb } };
}
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -40,303 +24,66 @@ export async function POST(request: NextRequest) {
precio: number; precio: number;
tiempoEntrega: string; tiempoEntrega: string;
entregables: string[]; entregables: string[];
esPersonalizado?: boolean;
horas?: number;
tarifaHora?: number;
modeloCobro?: string;
montoMinimo?: number;
horasIncluidas?: number;
opcion?: string;
}[]; }[];
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
observaciones: string; observaciones: string;
esDoble?: boolean;
opciones?: { "1"?: { titulo?: string; descripcion?: string; noIncluye?: string }; "2"?: { titulo?: string; descripcion?: string; noIncluye?: string } };
}; };
}; };
const fechaCot = new Date(draft.fecha); const fechaCot = new Date(draft.fecha);
const vigencia = calcularVigencia(fechaCot);
const empresa = draft.clienteEmpresa || draft.clienteNombre; const empresa = draft.clienteEmpresa || draft.clienteNombre;
const branding = await getConfigBranding(); const [branding, bancaria] = await Promise.all([
const bancaria = await getConfigBancaria(); getConfigBranding(),
const configColor = branding.colorPrimario || "#2563eb"; getConfigBancaria(),
const PRIMARY = hexToArgb(configColor); ]);
const PRIMARY_LIGHT = hexToArgb(configColor + "18");
const SECONDARY = hexToArgb(branding.colorSecundario || "#1e293b");
const WHITE = "FFFFFFFF";
const LIGHT_BG = "FFF8FAFC";
const MUTED = "FF64748B";
const BORDER_COLOR = "FFE2E8F0";
const workbook = new ExcelJS.Workbook(); const data: ExcelData = {
workbook.creator = "Cotizador E3"; razonSocial: bancaria.razon_social || "Cotizador E3",
workbook.created = new Date(); domicilioFiscal: bancaria.domicilio_fiscal || undefined,
numero: "BORRADOR",
clienteNombre: draft.clienteNombre,
clienteEmpresa: draft.clienteEmpresa,
asesorNombre: draft.asesorNombre,
fecha: fechaCot,
vigencia: calcularVigencia(fechaCot),
moneda: draft.moneda,
tipoCambio: draft.tipoCambio,
proyecto: draft.proyecto,
esquemaPago: draft.esquemaPago,
servicios: draft.servicios.map((s) => ({
nombre: s.nombre,
fase: s.fase,
tipoPago: s.tipoPago,
precio: s.precio,
tiempoEntrega: s.tiempoEntrega,
entregables: s.entregables ?? [],
modeloCobro: s.modeloCobro,
esPersonalizado: s.esPersonalizado,
horas: s.horas,
tarifaHora: s.tarifaHora,
montoMinimo: s.montoMinimo,
horasIncluidas: s.horasIncluidas,
opcion: s.opcion,
})),
esDoble: draft.esDoble,
opcionesMetadata: draft.esDoble ? draft.opciones ?? null : null,
planBucefaloNivel: draft.planBucefaloNivel,
colorPrimario: branding.colorPrimario || "#2563eb",
colorSecundario: branding.colorSecundario || "#1e293b",
};
const headerFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: WHITE } }; const buffer = await buildCotizacionExcel(data);
const labelFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri", color: { argb: MUTED } }; return new NextResponse(new Uint8Array(buffer), {
const valueFont: Partial<ExcelJS.Font> = { size: 10, name: "Calibri" };
const boldFont: Partial<ExcelJS.Font> = { bold: true, size: 10, name: "Calibri" };
const totalFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri" };
const smallFont: Partial<ExcelJS.Font> = { italic: true, size: 9, name: "Calibri", color: { argb: MUTED } };
const faseFont: Partial<ExcelJS.Font> = { bold: true, size: 11, name: "Calibri", color: { argb: PRIMARY } };
const ws = workbook.addWorksheet("HOJA RESUMEN");
ws.columns = [
{ width: 3 },
{ width: 22 }, { width: 22 }, { width: 16 },
{ width: 38 }, { width: 5 },
{ width: 20 }, { width: 5 },
{ width: 20 }, { width: 5 },
{ width: 18 },
];
ws.views = [{ showGridLines: false }];
ws.mergeCells("B2:K2");
ws.getCell("B2").value = bancaria.razon_social || "Cotizador E3";
ws.getCell("B2").font = { bold: true, size: 16, name: "Calibri", color: { argb: WHITE } };
ws.getCell("B2").alignment = { vertical: "middle" };
fillRow(ws, 2, "A", "K", PRIMARY);
ws.mergeCells("B3:K3");
ws.getCell("B3").value = "BORRADOR";
ws.getCell("B3").font = { bold: true, size: 11, name: "Calibri", color: { argb: WHITE } };
ws.getCell("B3").alignment = { vertical: "middle" };
fillRow(ws, 3, "A", "K", SECONDARY);
const infoStart = 5;
const infoLabels: [string, string, string, string][] = [
["B", "En atencion a:", "C", draft.clienteNombre],
["E", "Empresa:", "F", draft.clienteEmpresa || "—"],
["B", "Proyecto:", "C", draft.proyecto],
["E", "Moneda:", "F", draft.moneda],
["B", "Asesor:", "C", draft.asesorNombre],
["E", "Tipo de Cambio:", "F", draft.tipoCambio],
["B", "Fecha:", "C", ""],
["E", "Esquema de Pago:", "F", draft.esquemaPago],
];
for (let i = 0; i < infoLabels.length; i += 2) {
const r = infoStart + (i / 2);
const [lc1, lv1, vc1, vv1] = infoLabels[i];
const [lc2, lv2, vc2, vv2] = infoLabels[i + 1];
ws.getCell(`${lc1}${r}`).value = lv1;
ws.getCell(`${lc1}${r}`).font = labelFont;
ws.getCell(`${vc1}${r}`).value = vv1;
ws.getCell(`${vc1}${r}`).font = valueFont;
ws.getCell(`${lc2}${r}`).value = lv2;
ws.getCell(`${lc2}${r}`).font = labelFont;
ws.getCell(`${vc2}${r}`).value = vv2;
ws.getCell(`${vc2}${r}`).font = valueFont;
}
const fechaCell = ws.getCell(`C${infoStart + 6}`);
fechaCell.value = fechaCot;
fechaCell.numFmt = "DD/MM/YYYY";
fechaCell.font = valueFont;
ws.getCell(`B${infoStart + 7}`).value = "Vigencia:";
ws.getCell(`B${infoStart + 7}`).font = labelFont;
const vigenciaCell = ws.getCell(`C${infoStart + 7}`);
vigenciaCell.value = vigencia;
vigenciaCell.numFmt = "DD/MM/YYYY";
vigenciaCell.font = valueFont;
const tableStart = infoStart + 9;
const cols = ["B", "C", "D", "E", "K"];
const headers = ["Fase", "Tipo de Pago", "Servicio", "", "Precio"];
ws.mergeCells(`D${tableStart}:J${tableStart}`);
for (let i = 0; i < cols.length; i++) {
const cell = ws.getCell(`${cols[i]}${tableStart}`);
cell.value = headers[i];
cell.font = headerFont;
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
cell.alignment = { horizontal: i === cols.length - 1 ? "right" : "left", vertical: "middle" };
applyThinBorder(cell, PRIMARY);
}
ws.getRow(tableStart).height = 24;
const fases: Record<number, string> = { 0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3" };
const serviciosUnicos = draft.servicios.filter(s => s.tipoPago === "unico");
const serviciosMensuales = draft.servicios.filter(s => s.tipoPago === "mensual");
let row = tableStart + 1;
let currentFase = -1;
for (const serv of [...serviciosUnicos, ...serviciosMensuales]) {
if (serv.fase !== currentFase) {
currentFase = serv.fase;
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = fases[serv.fase] || `FASE ${serv.fase}`;
ws.getCell(`B${row}`).font = faseFont;
fillRow(ws, row, "B", "K", PRIMARY_LIGHT);
row++;
}
const isAlt = row % 2 === 0;
const rowBg = isAlt ? LIGHT_BG : WHITE;
ws.getCell(`B${row}`).value = "";
ws.getCell(`C${row}`).value = serv.tipoPago === "unico" ? "Unico" : "Mensual";
ws.getCell(`C${row}`).font = valueFont;
ws.mergeCells(`D${row}:J${row}`);
ws.getCell(`D${row}`).value = serv.nombre;
ws.getCell(`D${row}`).font = valueFont;
ws.getCell(`K${row}`).value = serv.precio;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = valueFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: rowBg } };
applyThinBorder(ws.getCell(`${c}${row}`), BORDER_COLOR);
}
row++;
}
row++;
const totalUnico = serviciosUnicos.reduce((s, x) => s + x.precio, 0);
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "Total Pago Unico";
ws.getCell(`B${row}`).font = totalFont;
ws.getCell(`K${row}`).value = totalUnico;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = totalFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row++;
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "(+ IVA)";
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
row++;
const totalMensual = serviciosMensuales.reduce((s, x) => s + x.precio, 0);
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "Total Pago Mensual";
ws.getCell(`B${row}`).font = totalFont;
ws.getCell(`K${row}`).value = totalMensual;
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = totalFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row++;
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = "(+ IVA)";
ws.getCell(`B${row}`).font = { ...smallFont, italic: false };
row += 2;
if (draft.planBucefaloNivel) {
ws.mergeCells(`B${row}:J${row}`);
ws.getCell(`B${row}`).value = `CRM Bucefalo - ${draft.planBucefaloNivel.charAt(0).toUpperCase() + draft.planBucefaloNivel.slice(1)}`;
ws.getCell(`B${row}`).font = boldFont;
ws.getCell(`K${row}`).value = bucefaloPrecio(draft.planBucefaloNivel);
ws.getCell(`K${row}`).numFmt = '$#,##0.00';
ws.getCell(`K${row}`).font = boldFont;
ws.getCell(`K${row}`).alignment = { horizontal: "right" };
for (const c of cols) {
applyThinBorder(ws.getCell(`${c}${row}`), PRIMARY);
ws.getCell(`${c}${row}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
}
row += 2;
}
ws.mergeCells(`B${row}:K${row}`);
ws.getCell(`B${row}`).value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles.";
ws.getCell(`B${row}`).font = smallFont;
for (const serv of draft.servicios) {
const safeName = serv.nombre.substring(0, 31).replace(/[/\\*?[\]:]/g, "");
const dw = workbook.addWorksheet(safeName);
dw.columns = [
{ width: 3 },
{ width: 35 }, { width: 5 }, { width: 15 },
{ width: 5 },
{ width: 35 }, { width: 5 }, { width: 15 },
{ width: 5 }, { width: 18 },
];
dw.views = [{ showGridLines: false }];
dw.mergeCells("B1:J1");
dw.getCell("B1").value = `${fases[serv.fase] || "FASE " + serv.fase}${serv.nombre}`;
dw.getCell("B1").font = { bold: true, size: 14, name: "Calibri", color: { argb: WHITE } };
dw.getCell("B1").alignment = { vertical: "middle" };
fillRow(dw, 1, "A", "J", PRIMARY);
dw.getRow(1).height = 30;
let r = 3;
dw.getCell(`B${r}`).value = "Cliente:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = draft.clienteEmpresa || draft.clienteNombre;
dw.getCell(`C${r}`).font = boldFont;
dw.getCell(`F${r}`).value = "No. Cotizacion:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = "BORRADOR";
dw.getCell(`G${r}`).font = boldFont;
r += 2;
dw.getCell(`B${r}`).value = "Servicio:";
dw.getCell(`B${r}`).font = labelFont;
dw.getCell(`C${r}`).value = serv.nombre;
dw.getCell(`C${r}`).font = { ...boldFont, size: 12, color: { argb: PRIMARY } };
dw.getCell(`F${r}`).value = "Tiempo de Entrega:";
dw.getCell(`F${r}`).font = labelFont;
dw.getCell(`G${r}`).value = serv.tiempoEntrega;
dw.getCell(`G${r}`).font = valueFont;
r += 2;
const thCols = ["B", "I"];
for (const c of thCols) {
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY } };
dw.getCell(`${c}${r}`).font = headerFont;
dw.getCell(`${c}${r}`).border = { bottom: { style: "thin", color: { argb: PRIMARY } } };
}
dw.getCell(`B${r}`).value = "Entregable";
dw.getCell(`I${r}`).value = "";
r++;
for (let i = 0; i < serv.entregables.length; i++) {
const isAlt = i % 2 === 1;
dw.getCell(`B${i + r}`).value = `${i + 1}. ${serv.entregables[i]}`;
dw.getCell(`B${i + r}`).font = valueFont;
const bg = isAlt ? LIGHT_BG : WHITE;
dw.getCell(`B${i + r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: bg } };
applyThinBorder(dw.getCell(`B${i + r}`), BORDER_COLOR);
}
r += serv.entregables.length + 1;
for (const c of ["B", "I"]) {
dw.getCell(`${c}${r}`).fill = { type: "pattern", pattern: "solid", fgColor: { argb: PRIMARY_LIGHT } };
applyThinBorder(dw.getCell(`${c}${r}`), PRIMARY);
}
dw.getCell(`B${r}`).value = `Total Pago ${serv.tipoPago === "unico" ? "Unico" : "Mensual"}`;
dw.getCell(`B${r}`).font = totalFont;
dw.getCell(`I${r}`).value = serv.precio;
dw.getCell(`I${r}`).numFmt = '$#,##0.00';
dw.getCell(`I${r}`).font = totalFont;
dw.getCell(`I${r}`).alignment = { horizontal: "right" };
r++;
dw.getCell(`B${r}`).value = "(+ IVA)";
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
r += 2;
dw.mergeCells(`B${r}:J${r}`);
dw.getCell(`B${r}`).value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA.";
dw.getCell(`B${r}`).font = smallFont;
r += 2;
dw.getCell(`B${r}`).value = bancaria.razon_social || "Cotizador E3";
dw.getCell(`B${r}`).font = { ...boldFont, color: { argb: PRIMARY } };
if (bancaria.domicilio_fiscal) {
r++;
dw.mergeCells(`B${r}:J${r}`);
dw.getCell(`B${r}`).value = bancaria.domicilio_fiscal;
dw.getCell(`B${r}`).font = { ...smallFont, italic: false };
}
}
const buffer = await workbook.xlsx.writeBuffer();
return new NextResponse(buffer, {
headers: { headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR.xlsx"`, "Content-Disposition": `attachment; filename="${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR.xlsx"`,
+16 -7
View File
@@ -10,7 +10,8 @@ export async function GET(
) { ) {
try { try {
const { id } = await params; const { id } = await params;
const cot = await prisma.cotizacion.findUnique({ const [cot, config, branding] = await Promise.all([
prisma.cotizacion.findUnique({
where: { id }, where: { id },
include: { include: {
cliente: true, cliente: true,
@@ -18,15 +19,14 @@ export async function GET(
servicios: { include: { servicioCatalogo: true } }, servicios: { include: { servicioCatalogo: true } },
planBucefalo: true, planBucefalo: true,
}, },
}); }),
getConfigBancaria(),
getConfigBranding(),
]);
if (!cot) { if (!cot) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 }); return NextResponse.json({ error: "No encontrada" }, { status: 404 });
} }
const config = await getConfigBancaria();
const branding = await getConfigBranding();
const empresa = cot.cliente.empresa || cot.cliente.nombre; const empresa = cot.cliente.empresa || cot.cliente.nombre;
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}`; const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(cot.cliente.nombre)} - ${cot.numero}`;
@@ -42,13 +42,22 @@ export async function GET(
proyecto: cot.proyecto, proyecto: cot.proyecto,
esquemaPago: cot.esquemaPago, esquemaPago: cot.esquemaPago,
servicios: cot.servicios.filter((s) => s.seleccionado).map((s) => ({ servicios: cot.servicios.filter((s) => s.seleccionado).map((s) => ({
nombre: s.servicioCatalogo?.nombre || "Servicio", nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
fase: s.fase, fase: s.fase,
tipoPago: s.tipoPago, tipoPago: s.tipoPago,
precio: s.precio, precio: s.precio,
tiempoEntrega: s.tiempoEntrega, tiempoEntrega: s.tiempoEntrega,
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [], entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
esPersonalizado: s.esPersonalizado,
horas: s.horas ?? undefined,
tarifaHora: s.tarifaHora ?? undefined,
modeloCobro: s.modeloCobro ?? undefined,
montoMinimo: s.montoMinimo ?? undefined,
horasIncluidas: s.horasIncluidas ?? undefined,
opcion: s.opcion ?? undefined,
})), })),
esDoble: cot.esDoble,
opcionesMetadata: cot.opcionesMetadata as never,
planBucefaloNivel: cot.planBucefalo?.nivel || null, planBucefaloNivel: cot.planBucefalo?.nivel || null,
planBucefaloPrecio: cot.planBucefalo?.precio || 0, planBucefaloPrecio: cot.planBucefalo?.precio || 0,
incluirBonos: cot.incluirBonos, incluirBonos: cot.incluirBonos,
+11
View File
@@ -24,9 +24,18 @@ export async function POST(request: NextRequest) {
precio: number; precio: number;
tiempoEntrega: string; tiempoEntrega: string;
entregables: string[]; entregables: string[];
esPersonalizado?: boolean;
horas?: number;
tarifaHora?: number;
modeloCobro?: string;
montoMinimo?: number;
horasIncluidas?: number;
opcion?: string;
}[]; }[];
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
observaciones: string; observaciones: string;
esDoble?: boolean;
opciones?: { "1"?: { titulo?: string; descripcion?: string; noIncluye?: string }; "2"?: { titulo?: string; descripcion?: string; noIncluye?: string } };
}; };
}; };
@@ -49,6 +58,8 @@ export async function POST(request: NextRequest) {
proyecto: draft.proyecto, proyecto: draft.proyecto,
esquemaPago: draft.esquemaPago, esquemaPago: draft.esquemaPago,
servicios: draft.servicios, servicios: draft.servicios,
esDoble: draft.esDoble,
opcionesMetadata: draft.esDoble ? draft.opciones ?? null : null,
planBucefaloNivel: draft.planBucefaloNivel, planBucefaloNivel: draft.planBucefaloNivel,
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0, planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
incluirBonos: draft.incluirBonos, incluirBonos: draft.incluirBonos,
+495 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Save, Save,
@@ -14,6 +14,9 @@ import {
Mail, Mail,
Phone, Phone,
Package, Package,
Clock,
Plus,
Trash2,
} from "lucide-react"; } from "lucide-react";
import clsx from "clsx"; import clsx from "clsx";
import { import {
@@ -24,7 +27,12 @@ import {
calcularVigencia, calcularVigencia,
generarNumeroCotizacion, generarNumeroCotizacion,
bucefaloPrecio, bucefaloPrecio,
calcularPrecioHoras,
describirRetainer,
calcularTotalesOpcion,
TARIFA_HORA_DEFAULT,
} from "@/lib/calculators"; } from "@/lib/calculators";
import type { MetaOpcion } from "@/lib/calculators";
import { import {
useCotizacionStore, useCotizacionStore,
ServicioSeleccionado, ServicioSeleccionado,
@@ -32,6 +40,7 @@ import {
import { import {
ExportPDFButtonDraft, ExportPDFButtonDraft,
ExportExcelButtonDraft, ExportExcelButtonDraft,
PreviewPDFButtonDraft,
} from "@/components/ExportButtons"; } from "@/components/ExportButtons";
export interface ServicioCatalogo { export interface ServicioCatalogo {
@@ -75,6 +84,8 @@ export interface ExistingData {
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
estado: string; estado: string;
servicios: ServicioSeleccionado[]; servicios: ServicioSeleccionado[];
esDoble?: boolean;
opciones?: { "1"?: MetaOpcion; "2"?: MetaOpcion };
} }
export interface PaqueteServicio { export interface PaqueteServicio {
@@ -153,11 +164,18 @@ export function CotizacionForm({
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento); store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
store.setField("observaciones", existingData.observaciones); store.setField("observaciones", existingData.observaciones);
store.setField("planBucefaloNivel", existingData.planBucefaloNivel); store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
store.setField("esDoble", existingData.esDoble ?? false);
store.setField("opciones", existingData.opciones ?? {});
store.setServicios(existingData.servicios); store.setServicios(existingData.servicios);
} }
}, [mode, existingData]); }, [mode, existingData]);
const selectedIds = new Set(store.draft.servicios.map((s) => s.catalogoId)); const esDoble = store.draft.esDoble;
const selectedIds = useMemo(
() => new Set(store.draft.servicios.map((s) => s.catalogoId)),
[store.draft.servicios]
);
const toggleFase = (fase: number) => { const toggleFase = (fase: number) => {
setExpandedFases((prev) => { setExpandedFases((prev) => {
@@ -197,6 +215,99 @@ export function CotizacionForm({
store.updateServicio(catalogoId, { precio }); store.updateServicio(catalogoId, { precio });
}; };
const handleAddCustom = () => {
const id = `custom-${crypto.randomUUID()}`;
store.toggleServicio({
catalogoId: id,
nombre: "",
fase: 1,
tipoPago: "unico",
precio: calcularPrecioHoras(1, TARIFA_HORA_DEFAULT),
tiempoEntrega: "A convenir",
entregables: [],
esPersonalizado: true,
horas: 1,
tarifaHora: TARIFA_HORA_DEFAULT,
});
};
const handleCustomHoras = (catalogoId: string, horas: number) => {
const s = store.draft.servicios.find((x) => x.catalogoId === catalogoId);
store.updateServicio(catalogoId, {
horas,
precio: calcularPrecioHoras(horas, s?.tarifaHora ?? TARIFA_HORA_DEFAULT),
});
};
const handleCustomTarifa = (catalogoId: string, tarifaHora: number) => {
const s = store.draft.servicios.find((x) => x.catalogoId === catalogoId);
store.updateServicio(catalogoId, {
tarifaHora,
precio: calcularPrecioHoras(s?.horas ?? 0, tarifaHora),
});
};
const handleAddRetainer = () => {
const id = `custom-${crypto.randomUUID()}`;
store.toggleServicio({
catalogoId: id,
nombre: "",
fase: 1,
tipoPago: "mensual",
precio: 0,
tiempoEntrega: "Mensual",
entregables: [],
esPersonalizado: true,
modeloCobro: "retainer",
montoMinimo: 0,
horasIncluidas: 40,
tarifaHora: TARIFA_HORA_DEFAULT,
});
};
// Cambia el modelo de cobro de una partida y recalcula su precio.
const handleModeloChange = (catalogoId: string, modelo: string) => {
const s = store.draft.servicios.find((x) => x.catalogoId === catalogoId);
if (!s) return;
if (modelo === "retainer") {
store.updateServicio(catalogoId, {
modeloCobro: "retainer",
tipoPago: "mensual",
montoMinimo: s.montoMinimo ?? 0,
horasIncluidas: s.horasIncluidas ?? 40,
tarifaHora: s.tarifaHora ?? TARIFA_HORA_DEFAULT,
precio: s.montoMinimo ?? 0,
});
} else {
store.updateServicio(catalogoId, {
modeloCobro: "horas",
precio: calcularPrecioHoras(s.horas ?? 1, s.tarifaHora ?? TARIFA_HORA_DEFAULT),
});
}
};
// Retainer: el precio cotizado es el monto minimo mensual. La tarifa/hora y las
// horas incluidas son informativas (las horas adicionales se facturan aparte).
const handleRetainerMonto = (catalogoId: string, montoMinimo: number) => {
store.updateServicio(catalogoId, { montoMinimo, precio: montoMinimo });
};
const customServicios = store.draft.servicios.filter((s) => s.esPersonalizado);
// Selector de opcion (1 / 2 / ambas) para una partida en modo doble propuesta.
const opcionSelect = (catalogoId: string, value?: string) => (
<select
value={value ?? "ambas"}
onChange={(e) => store.updateServicio(catalogoId, { opcion: e.target.value })}
className="px-2 py-1 border border-primary/40 rounded text-xs bg-white focus:outline-none focus:ring-1 focus:ring-primary"
title="Opcion a la que pertenece esta partida"
>
<option value="1">Opcion 1</option>
<option value="2">Opcion 2</option>
<option value="ambas">Ambas</option>
</select>
);
const buildRequestBody = () => ({ const buildRequestBody = () => ({
...(mode === "new" && { ...(mode === "new" && {
numero: generarNumeroCotizacion( numero: generarNumeroCotizacion(
@@ -217,6 +328,8 @@ export function CotizacionForm({
esquemaPago: store.draft.esquemaPago, esquemaPago: store.draft.esquemaPago,
incluirBonos: store.draft.incluirBonos, incluirBonos: store.draft.incluirBonos,
incluirFinanciamiento: store.draft.incluirFinanciamiento, incluirFinanciamiento: store.draft.incluirFinanciamiento,
esDoble: store.draft.esDoble,
opciones: store.draft.esDoble ? store.draft.opciones : undefined,
observaciones: store.draft.observaciones, observaciones: store.draft.observaciones,
cliente: { cliente: {
nombre: store.draft.clienteNombre, nombre: store.draft.clienteNombre,
@@ -274,13 +387,26 @@ export function CotizacionForm({
const ivaUnico = totalUnico * IVA_RATE; const ivaUnico = totalUnico * IVA_RATE;
const ivaMensual = totalMensual * IVA_RATE; const ivaMensual = totalMensual * IVA_RATE;
// Totales por opcion (doble propuesta). "ambas" suma en las dos.
const totalesOpcion = useMemo(
() => ({
"1": calcularTotalesOpcion(store.draft.servicios, "1"),
"2": calcularTotalesOpcion(store.draft.servicios, "2"),
}),
[store.draft.servicios]
);
const fases = [0, 1, 2, 3]; const fases = [0, 1, 2, 3];
const serviciosPorFase = fases.reduce( const serviciosPorFase = useMemo(
() =>
[0, 1, 2, 3].reduce(
(acc, fase) => { (acc, fase) => {
acc[fase] = servicios.filter((s) => s.fase === fase); acc[fase] = servicios.filter((s) => s.fase === fase);
return acc; return acc;
}, },
{} as Record<number, ServicioCatalogo[]> {} as Record<number, ServicioCatalogo[]>
),
[servicios]
); );
const title = const title =
@@ -305,6 +431,7 @@ export function CotizacionForm({
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PreviewPDFButtonDraft draft={store.draft} />
<ExportPDFButtonDraft draft={store.draft} /> <ExportPDFButtonDraft draft={store.draft} />
<ExportExcelButtonDraft draft={store.draft} /> <ExportExcelButtonDraft draft={store.draft} />
<button <button
@@ -451,6 +578,72 @@ export function CotizacionForm({
</div> </div>
</div> </div>
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={esDoble}
onChange={(e) => store.setField("esDoble", e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="font-semibold text-lg">Doble propuesta</span>
<span className="text-xs text-muted ml-2">
Presenta dos opciones comparables; el cliente elige una.
</span>
</label>
{esDoble && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{(["1", "2"] as const).map((op) => (
<div
key={op}
className="border border-primary/30 rounded-lg p-4 bg-primary-light/10"
>
<h3 className="font-semibold text-sm mb-2 text-primary">
Opcion {op}
</h3>
<label className="block text-xs font-medium text-muted mb-1">
Titulo
</label>
<input
type="text"
value={store.draft.opciones[op]?.titulo ?? ""}
onChange={(e) =>
store.updateOpcionMeta(op, { titulo: e.target.value })
}
placeholder={op === "1" ? "Ej. Capacitacion y revision" : "Ej. Arquitectura completa"}
className={INPUT_CLS}
/>
<label className="block text-xs font-medium text-muted mb-1 mt-3">
Descripcion / scope
</label>
<textarea
value={store.draft.opciones[op]?.descripcion ?? ""}
onChange={(e) =>
store.updateOpcionMeta(op, { descripcion: e.target.value })
}
rows={3}
placeholder="Que incluye esta opcion..."
className={INPUT_CLS}
/>
<label className="block text-xs font-medium text-muted mb-1 mt-3">
Lo que NO incluye
</label>
<textarea
value={store.draft.opciones[op]?.noIncluye ?? ""}
onChange={(e) =>
store.updateOpcionMeta(op, { noIncluye: e.target.value })
}
rows={2}
placeholder="Exclusiones de esta opcion..."
className={INPUT_CLS}
/>
</div>
))}
</div>
)}
</div>
{mode === "new" && paquetes && paquetes.length > 0 && ( {mode === "new" && paquetes && paquetes.length > 0 && (
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6"> <div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<h2 className="font-semibold text-lg mb-3"> <h2 className="font-semibold text-lg mb-3">
@@ -604,6 +797,8 @@ export function CotizacionForm({
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{isSelected && esDoble &&
opcionSelect(serv.id, servicioData?.opcion)}
{isSelected && ( {isSelected && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<DollarSign className="w-3.5 h-3.5 text-muted" /> <DollarSign className="w-3.5 h-3.5 text-muted" />
@@ -669,6 +864,266 @@ export function CotizacionForm({
); );
})} })}
<div className="bg-card-bg rounded-xl border border-border p-5 mb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-primary" />
Servicios por tiempo (horas / retainer)
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleAddCustom}
className="flex items-center gap-1 px-3 py-1.5 border border-primary text-primary rounded-lg text-sm font-medium hover:bg-primary-light/30"
>
<Plus className="w-4 h-4" />
Por horas
</button>
<button
onClick={handleAddRetainer}
className="flex items-center gap-1 px-3 py-1.5 border border-primary text-primary rounded-lg text-sm font-medium hover:bg-primary-light/30"
>
<Plus className="w-4 h-4" />
Retainer
</button>
</div>
</div>
{customServicios.length === 0 ? (
<p className="text-xs text-muted">
Agrega partidas cobradas por tiempo: <b>por horas</b> (horas x tarifa) o{" "}
<b>retainer</b> (importe minimo mensual + horas adicionales que se facturan
aparte). Aparecen en el resumen y en la cotizacion junto al resto de servicios.
</p>
) : (
<div className="space-y-3">
{customServicios.map((s) => (
<div
key={s.catalogoId}
className="border border-primary/40 bg-primary-light/10 rounded-lg p-3"
>
<div className="grid grid-cols-2 md:grid-cols-12 gap-2">
<div className="col-span-2 md:col-span-4">
<label className="block text-xs font-medium text-muted mb-1">
Concepto
</label>
<input
type="text"
value={s.nombre}
onChange={(e) =>
store.updateServicio(s.catalogoId, {
nombre: e.target.value,
})
}
placeholder={
s.modeloCobro === "retainer"
? "Ej. Acompanamiento mensual"
: "Ej. Horas de consultoria"
}
className="w-full px-2 py-1.5 border border-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-medium text-muted mb-1">
Modelo
</label>
<select
value={s.modeloCobro === "retainer" ? "retainer" : "horas"}
onChange={(e) =>
handleModeloChange(s.catalogoId, e.target.value)
}
className="w-full px-2 py-1.5 border border-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary bg-white"
>
<option value="horas">Por horas</option>
<option value="retainer">Retainer</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-muted mb-1">
Fase
</label>
<select
value={s.fase}
onChange={(e) =>
store.updateServicio(s.catalogoId, {
fase: parseInt(e.target.value, 10),
})
}
className="w-full px-2 py-1.5 border border-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary bg-white"
>
{[0, 1, 2, 3].map((f) => (
<option key={f} value={f}>
Fase {f}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-muted mb-1">
Tipo
</label>
{s.modeloCobro === "retainer" ? (
<div className="w-full px-2 py-1.5 border border-border rounded text-sm bg-gray-50 text-muted">
Mensual
</div>
) : (
<select
value={s.tipoPago}
onChange={(e) =>
store.updateServicio(s.catalogoId, {
tipoPago: e.target.value,
})
}
className="w-full px-2 py-1.5 border border-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary bg-white"
>
<option value="unico">Pago Unico</option>
<option value="mensual">Mensual</option>
</select>
)}
</div>
<div className="md:col-span-1 flex items-end justify-end">
<button
onClick={() => store.removeServicio(s.catalogoId)}
className="p-1.5 text-muted hover:text-red-600 hover:bg-red-50 rounded"
title="Eliminar partida"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{s.modeloCobro === "retainer" ? (
<div className="grid grid-cols-2 md:grid-cols-12 gap-2 mt-2">
<div className="md:col-span-4">
<label className="block text-xs font-medium text-muted mb-1">
Monto minimo mensual
</label>
<input
type="number"
min={0}
value={s.montoMinimo ?? 0}
onChange={(e) =>
handleRetainerMonto(
s.catalogoId,
parseFloat(e.target.value) || 0
)
}
className="w-full px-2 py-1.5 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="md:col-span-4">
<label className="block text-xs font-medium text-muted mb-1">
Horas incluidas
</label>
<input
type="number"
min={0}
value={s.horasIncluidas ?? 0}
onChange={(e) =>
store.updateServicio(s.catalogoId, {
horasIncluidas: parseFloat(e.target.value) || 0,
})
}
className="w-full px-2 py-1.5 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="md:col-span-4">
<label className="block text-xs font-medium text-muted mb-1">
Tarifa hora adicional
</label>
<input
type="number"
min={0}
value={s.tarifaHora ?? 0}
onChange={(e) =>
store.updateServicio(s.catalogoId, {
tarifaHora: parseFloat(e.target.value) || 0,
})
}
className="w-full px-2 py-1.5 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-12 gap-2 mt-2">
<div className="md:col-span-3">
<label className="block text-xs font-medium text-muted mb-1">
Horas
</label>
<input
type="number"
min={0}
value={s.horas ?? 0}
onChange={(e) =>
handleCustomHoras(
s.catalogoId,
parseFloat(e.target.value) || 0
)
}
className="w-full px-2 py-1.5 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-medium text-muted mb-1">
Tarifa/hora
</label>
<input
type="number"
min={0}
value={s.tarifaHora ?? 0}
onChange={(e) =>
handleCustomTarifa(
s.catalogoId,
parseFloat(e.target.value) || 0
)
}
className="w-full px-2 py-1.5 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
)}
<div className="flex items-center justify-between mt-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Entrega:</span>
<input
type="text"
value={s.tiempoEntrega}
onChange={(e) =>
store.updateServicio(s.catalogoId, {
tiempoEntrega: e.target.value,
})
}
className="w-40 px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
{esDoble && (
<>
<span className="text-xs text-muted ml-2">Opcion:</span>
{opcionSelect(s.catalogoId, s.opcion)}
</>
)}
</div>
<span className="font-semibold">
{s.modeloCobro === "retainer" ? (
<span className="text-primary">
{describirRetainer(
s.montoMinimo ?? 0,
s.horasIncluidas ?? 0,
s.tarifaHora ?? 0
)}
</span>
) : (
<>
{(s.horas ?? 0)} h x {formatCurrency(s.tarifaHora ?? 0)} ={" "}
<span className="text-primary">
{formatCurrency(s.precio)}
</span>
</>
)}
</span>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-card-bg rounded-xl border border-border p-5 mb-4"> <div className="bg-card-bg rounded-xl border border-border p-5 mb-4">
<h3 className="font-semibold mb-3"> <h3 className="font-semibold mb-3">
Plan Bucefalo CRM (Opcional) Plan Bucefalo CRM (Opcional)
@@ -782,6 +1237,43 @@ export function CotizacionForm({
<div className="px-5 py-4 border-b border-border bg-gray-50"> <div className="px-5 py-4 border-b border-border bg-gray-50">
<h3 className="font-semibold">Resumen</h3> <h3 className="font-semibold">Resumen</h3>
</div> </div>
{esDoble && (
<div className="p-5 space-y-4 border-b border-border">
{(["1", "2"] as const).map((op) => {
const t = totalesOpcion[op];
const meta = store.draft.opciones[op];
return (
<div key={op} className="border border-primary/30 rounded-lg p-3 bg-primary-light/10">
<h4 className="text-sm font-semibold text-primary">
Opcion {op}
{meta?.titulo ? `: ${meta.titulo}` : ""}
</h4>
<div className="flex justify-between text-xs mt-2">
<span>Total unico (c/IVA)</span>
<span className="font-medium">
{formatCurrency(t.totalUnico * (1 + IVA_RATE))}
</span>
</div>
<div className="flex justify-between text-xs mt-1">
<span>Total mensual (c/IVA)</span>
<span className="font-medium">
{formatCurrency(t.totalMensual * (1 + IVA_RATE))}
</span>
</div>
{t.horas > 0 && (
<div className="flex justify-between text-xs mt-1 text-muted">
<span>Horas estimadas</span>
<span>{t.horas} h</span>
</div>
)}
</div>
);
})}
<p className="text-[11px] text-muted">
Las partidas marcadas <b>Ambas</b> suman en las dos opciones.
</p>
</div>
)}
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<div> <div>
<h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-2"> <h4 className="text-xs font-medium text-muted uppercase tracking-wide mb-2">
+130 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { FileDown, FileSpreadsheet, Loader2 } from "lucide-react"; import { Eye, FileDown, FileSpreadsheet, Loader2, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
function getFilenameFromHeaders(headers: Headers, fallback: string): string { function getFilenameFromHeaders(headers: Headers, fallback: string): string {
@@ -123,3 +123,132 @@ export function ExportPDFButtonDraft({ draft }: { draft: unknown }) {
</button> </button>
); );
} }
function PDFPreviewModal({
url,
filename,
onClose,
}: {
url: string;
filename: string;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/60"
onClick={onClose}
>
<div
className="flex items-center justify-between gap-4 px-4 py-3 bg-white border-b border-border"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-sm truncate">{filename}</h3>
<div className="flex items-center gap-2 shrink-0">
<a
href={url}
download={filename}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
>
<FileDown className="w-4 h-4 text-red-600" />
Descargar
</a>
<button
onClick={onClose}
aria-label="Cerrar previsualización"
className="p-2 border border-border rounded-lg hover:bg-gray-50"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 p-4" onClick={(e) => e.stopPropagation()}>
<iframe
src={url}
title="Previsualización del PDF"
className="w-full h-full rounded-lg bg-white border border-border"
/>
</div>
</div>
);
}
function usePDFPreview() {
const [loading, setLoading] = useState(false);
const [preview, setPreview] = useState<{ url: string; filename: string } | null>(null);
const open = async (fetcher: () => Promise<Response>, fallback: string) => {
setLoading(true);
try {
const res = await fetcher();
if (res.ok) {
const filename = getFilenameFromHeaders(res.headers, fallback);
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
setPreview({ url, filename });
} else {
alert("Error al generar la previsualización");
}
} catch {
alert("Error al generar la previsualización");
} finally {
setLoading(false);
}
};
const close = () => {
if (preview) window.URL.revokeObjectURL(preview.url);
setPreview(null);
};
return { loading, preview, open, close };
}
export function PreviewPDFButtonSaved({ cotizacionId }: { cotizacionId: string }) {
const { loading, preview, open, close } = usePDFPreview();
return (
<>
<button
onClick={() =>
open(() => fetch(`/api/export/pdf/${cotizacionId}`), "COTIZACION.pdf")
}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4 text-blue-600" />}
Previsualizar
</button>
{preview && (
<PDFPreviewModal url={preview.url} filename={preview.filename} onClose={close} />
)}
</>
);
}
export function PreviewPDFButtonDraft({ draft }: { draft: unknown }) {
const { loading, preview, open, close } = usePDFPreview();
return (
<>
<button
onClick={() =>
open(
() =>
fetch("/api/export/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft }),
}),
"COTIZACION.pdf"
)
}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4 text-blue-600" />}
Previsualizar
</button>
{preview && (
<PDFPreviewModal url={preview.url} filename={preview.filename} onClose={close} />
)}
</>
);
}
+60
View File
@@ -1,5 +1,65 @@
export const IVA_RATE = 0.16; export const IVA_RATE = 0.16;
// Tarifa por hora sugerida para partidas personalizadas (Hora Centinela). El asesor puede ajustarla por partida.
export const TARIFA_HORA_DEFAULT = 700;
export function calcularPrecioHoras(horas: number, tarifaHora: number): number {
return Math.round((horas || 0) * (tarifaHora || 0) * 100) / 100;
}
// Modelos de cobro de una partida. "retainer" = importe minimo mensual fijo +
// tarifa de horas adicionales (las horas extra se facturan aparte, no suman al total).
export const MODELOS_COBRO: Record<string, string> = {
fijo: "Precio fijo",
horas: "Por horas",
retainer: "Retainer (minimo + adicionales)",
};
// Texto descriptivo de un retainer, reutilizado por UI, PDF y Excel.
export function describirRetainer(
montoMinimo: number,
horasIncluidas: number,
tarifaHora: number
): string {
const partes = [`${formatCurrency(montoMinimo || 0)}/mes`];
if (horasIncluidas) partes.push(`incluye ${horasIncluidas} hr`);
if (tarifaHora) partes.push(`adicional ${formatCurrency(tarifaHora)}/hr (se factura aparte)`);
return partes.join(" · ");
}
// Doble propuesta: cada partida puede ir en la Opcion 1, la Opcion 2 o en ambas.
// "ambas" aparece (y suma) en las dos opciones. null = cotizacion normal (sin doble propuesta).
export const OPCIONES = ["1", "2", "ambas"] as const;
export type OpcionPropuesta = (typeof OPCIONES)[number];
export interface MetaOpcion {
titulo?: string;
descripcion?: string;
noIncluye?: string;
}
// Totales de una opcion: suma las partidas de esa opcion + las marcadas "ambas".
// Respeta la regla de retainer: el total usa `precio`, no `horas x tarifa`. Las horas
// son informativas para la tabla comparativa.
export function calcularTotalesOpcion(
servicios: Array<{ opcion?: string | null; tipoPago: string; precio: number; horas?: number | null }>,
opcion: "1" | "2"
): { totalUnico: number; totalMensual: number; horas: number } {
const rel = servicios.filter((s) => s.opcion === "ambas" || s.opcion === opcion);
const totalUnico = rel
.filter((s) => s.tipoPago === "unico")
.reduce((a, s) => a + (s.precio || 0), 0);
const totalMensual = rel
.filter((s) => s.tipoPago === "mensual")
.reduce((a, s) => a + (s.precio || 0), 0);
const horas = rel.reduce((a, s) => a + (s.horas || 0), 0);
return {
totalUnico: Math.round(totalUnico * 100) / 100,
totalMensual: Math.round(totalMensual * 100) / 100,
horas: Math.round(horas * 100) / 100,
};
}
export const FASES: Record<number, string> = { export const FASES: Record<number, string> = {
0: "FASE 0 - Auditoria / Acompanamiento", 0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura", 1: "FASE 1 - Setup e Infraestructura",
+107 -3
View File
@@ -1,5 +1,5 @@
import PDFDocument from "pdfkit"; import PDFDocument from "pdfkit";
import { FASES_SHORT as FASES } from "./calculators"; import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
interface ServicioPDF { interface ServicioPDF {
nombre: string; nombre: string;
@@ -8,6 +8,24 @@ interface ServicioPDF {
precio: number; precio: number;
tiempoEntrega: string; tiempoEntrega: string;
entregables: string[]; entregables: string[];
esPersonalizado?: boolean;
horas?: number;
tarifaHora?: number;
modeloCobro?: string;
montoMinimo?: number;
horasIncluidas?: number;
opcion?: string | null;
}
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
function detalleModeloPDF(serv: ServicioPDF): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
} }
interface CotizacionPDFData { interface CotizacionPDFData {
@@ -22,6 +40,8 @@ interface CotizacionPDFData {
proyecto: string; proyecto: string;
esquemaPago: string; esquemaPago: string;
servicios: ServicioPDF[]; servicios: ServicioPDF[];
esDoble?: boolean;
opcionesMetadata?: { "1"?: MetaOpcion; "2"?: MetaOpcion } | null;
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
planBucefaloPrecio: number; planBucefaloPrecio: number;
incluirBonos: boolean; incluirBonos: boolean;
@@ -180,6 +200,7 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
const colTiempo = L + W - 120; const colTiempo = L + W - 120;
const colPrecio = L + W - 60; const colPrecio = L + W - 60;
function drawTableHeader() {
rowBg(y, 16, LIGHT_BG); rowBg(y, 16, LIGHT_BG);
doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK); doc.font("Helvetica-Bold").fontSize(7).fillColor(DARK);
doc.text("Servicio", colNombre + 6, y + 4); doc.text("Servicio", colNombre + 6, y + 4);
@@ -187,6 +208,7 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.text("Entrega", colTiempo + 4, y + 4); doc.text("Entrega", colTiempo + 4, y + 4);
doc.text("Precio", colPrecio, y + 4, { width: 60, align: "right" }); doc.text("Precio", colPrecio, y + 4, { width: 60, align: "right" });
y += 18; y += 18;
}
const unicos = data.servicios.filter((s) => s.tipoPago === "unico"); const unicos = data.servicios.filter((s) => s.tipoPago === "unico");
const mensuales = data.servicios.filter((s) => s.tipoPago === "mensual"); const mensuales = data.servicios.filter((s) => s.tipoPago === "mensual");
@@ -205,7 +227,8 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
y += 16; y += 16;
} }
const servH = 13 + (serv.entregables ? Math.ceil(serv.entregables.length / 2) * 8 : 0) + 5; const detalleModelo = detalleModeloPDF(serv);
const servH = 13 + (detalleModelo ? 9 : 0) + (serv.entregables ? Math.ceil(serv.entregables.length / 2) * 8 : 0) + 5;
y = need(servH, y); y = need(servH, y);
doc.font("Helvetica").fontSize(8).fillColor(DARK); doc.font("Helvetica").fontSize(8).fillColor(DARK);
@@ -217,6 +240,12 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
doc.text(fmt(serv.precio), colPrecio, y, { width: 60, align: "right" }); doc.text(fmt(serv.precio), colPrecio, y, { width: 60, align: "right" });
y += 13; y += 13;
if (detalleModelo) {
doc.font("Helvetica-Oblique").fontSize(6.5).fillColor(MUTED);
doc.text(detalleModelo, colNombre + 10, y, { width: colTipo - colNombre - 20 });
y += 9;
}
if (serv.entregables && serv.entregables.length > 0) { if (serv.entregables && serv.entregables.length > 0) {
const col1X = colNombre + 10; const col1X = colNombre + 10;
const col2X = colNombre + W * 0.48; const col2X = colNombre + W * 0.48;
@@ -251,8 +280,84 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
return y; return y;
} }
// Encabezado de una opcion (titulo, descripcion, exclusiones) en doble propuesta.
function drawOpcionHeader(op: "1" | "2") {
const meta = data.opcionesMetadata?.[op] || {};
y = need(30, y);
y += 4;
doc.rect(L, y, W, 18).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(WHITE)
.text(`OPCION ${op}${meta.titulo ? ": " + meta.titulo : ""}`, L + 8, y + 4, { width: W - 16 });
y += 22;
if (meta.descripcion) {
const h = txtHeight(meta.descripcion, W - 12, 7.5);
y = need(h + 4, y);
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(meta.descripcion, L + 6, y, { width: W - 12 });
y += h + 4;
}
if (meta.noIncluye) {
const label = `No incluye: ${meta.noIncluye}`;
const h = txtHeight(label, W - 12, 7);
y = need(h + 4, y);
doc.font("Helvetica-Oblique").fontSize(7).fillColor(MUTED).text(label, L + 6, y, { width: W - 12 });
y += h + 6;
}
}
// Tabla comparativa final: totales (con IVA) y horas por opcion.
function drawComparativa() {
const t1 = calcularTotalesOpcion(data.servicios, "1");
const t2 = calcularTotalesOpcion(data.servicios, "2");
y = need(90, y);
y += 6;
doc.rect(L, y, 3, 10).fill(PRIMARY);
doc.font("Helvetica-Bold").fontSize(10).fillColor(DARK).text("Comparativa de opciones", L + 10, y);
y += 18;
const cLabel = L;
const cOp1 = L + W * 0.45;
const cOp2 = L + W * 0.72;
const t1Tit = data.opcionesMetadata?.["1"]?.titulo;
const t2Tit = data.opcionesMetadata?.["2"]?.titulo;
rowBg(y, 16, LIGHT_BG);
doc.font("Helvetica-Bold").fontSize(7.5).fillColor(DARK);
doc.text("Concepto", cLabel + 6, y + 4);
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
y += 18;
const filas: [string, string, string][] = [
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
];
for (const [lab, v1, v2] of filas) {
y = need(14, y);
doc.font("Helvetica").fontSize(8).fillColor(DARK).text(lab, cLabel + 6, y);
doc.font("Helvetica-Bold").fontSize(8).fillColor(DARK).text(v1, cOp1, y, { width: W * 0.27 - 4 });
doc.text(v2, cOp2, y, { width: W * 0.28 - 4 });
doc.save();
doc.moveTo(cLabel + 6, y + 12).lineTo(L + W, y + 12).strokeColor("#e5e7eb").lineWidth(0.2).stroke();
doc.restore();
y += 14;
}
y += 8;
}
if (data.esDoble) {
for (const op of ["1", "2"] as const) {
const inOp = (s: ServicioPDF) => s.opcion === op || s.opcion === "ambas";
drawOpcionHeader(op);
drawTableHeader();
const u = unicos.filter(inOp);
const me = mensuales.filter(inOp);
if (u.length > 0) y = drawSection(u, "Unico", `Total Pago Unico - Opcion ${op}`);
if (me.length > 0) y = drawSection(me, "Mensual", `Total Pago Mensual - Opcion ${op}`);
}
drawComparativa();
} else {
drawTableHeader();
if (unicos.length > 0) y = drawSection(unicos, "Unico", "Total Pago Unico"); if (unicos.length > 0) y = drawSection(unicos, "Unico", "Total Pago Unico");
if (mensuales.length > 0) y = drawSection(mensuales, "Mensual", "Total Pago Mensual"); if (mensuales.length > 0) y = drawSection(mensuales, "Mensual", "Total Pago Mensual");
}
if (data.planBucefaloNivel) { if (data.planBucefaloNivel) {
y = need(20, y); y = need(20, y);
@@ -350,7 +455,6 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
const clabeNac = cfg.clabe_interbancaria || cfg.cuenta_nacional || ""; const clabeNac = cfg.clabe_interbancaria || cfg.cuenta_nacional || "";
const cuentaNac = cfg.cuenta_nacional || ""; const cuentaNac = cfg.cuenta_nacional || "";
const bancoNac = cfg.cuenta_nacional ? "BBVA" : ""; const bancoNac = cfg.cuenta_nacional ? "BBVA" : "";
const cuentaInt = (cfg.cuenta_internacional_swift || cfg.cuenta_internacional || "").split("\n").filter(Boolean);
const swiftMatch = cfg.cuenta_internacional_swift?.match(/SWIFT[^:]*:\s*(\S+)/i); const swiftMatch = cfg.cuenta_internacional_swift?.match(/SWIFT[^:]*:\s*(\S+)/i);
const clabeIntMatch = cfg.cuenta_internacional_swift?.match(/CLABE[^:]*:\s*(\S+)/i); const clabeIntMatch = cfg.cuenta_internacional_swift?.match(/CLABE[^:]*:\s*(\S+)/i);
const swift = swiftMatch?.[1] || "BCMRMXMMPYM"; const swift = swiftMatch?.[1] || "BCMRMXMMPYM";
+23
View File
@@ -8,8 +8,26 @@ const servicioSchema = z.object({
precio: z.number().min(0), precio: z.number().min(0),
tiempoEntrega: z.string().min(1), tiempoEntrega: z.string().min(1),
entregables: z.array(z.string()), entregables: z.array(z.string()),
esPersonalizado: z.boolean().optional(),
horas: z.number().min(0).optional(),
tarifaHora: z.number().min(0).optional(),
modeloCobro: z.enum(["fijo", "horas", "retainer"]).optional(),
montoMinimo: z.number().min(0).optional(),
horasIncluidas: z.number().min(0).optional(),
opcion: z.enum(["1", "2", "ambas"]).optional(),
}); });
const metaOpcionSchema = z.object({
titulo: z.string().optional(),
descripcion: z.string().optional(),
noIncluye: z.string().optional(),
});
// { "1": { titulo, descripcion, noIncluye }, "2": {...} }
const opcionesSchema = z
.object({ "1": metaOpcionSchema.optional(), "2": metaOpcionSchema.optional() })
.optional();
export const cotizacionPostSchema = z.object({ export const cotizacionPostSchema = z.object({
numero: z.string().min(1), numero: z.string().min(1),
fecha: z.coerce.date(), fecha: z.coerce.date(),
@@ -20,6 +38,8 @@ export const cotizacionPostSchema = z.object({
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]), esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
incluirBonos: z.boolean(), incluirBonos: z.boolean(),
incluirFinanciamiento: z.boolean(), incluirFinanciamiento: z.boolean(),
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(), observaciones: z.string(),
asesorId: z.string().min(1), asesorId: z.string().min(1),
cliente: z.object({ cliente: z.object({
@@ -46,6 +66,8 @@ export const cotizacionPutSchema = z.object({
esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]), esquemaPago: z.enum(["Pago Unico", "Mensual", "Pago Unico/Mensual"]),
incluirBonos: z.boolean(), incluirBonos: z.boolean(),
incluirFinanciamiento: z.boolean(), incluirFinanciamiento: z.boolean(),
esDoble: z.boolean().optional(),
opciones: opcionesSchema,
observaciones: z.string(), observaciones: z.string(),
cliente: z.object({ cliente: z.object({
nombre: z.string().min(1, "Cliente nombre es requerido"), nombre: z.string().min(1, "Cliente nombre es requerido"),
@@ -77,6 +99,7 @@ export const servicioCatalogoSchema = z.object({
entregablesDefault: z.array(z.string()), entregablesDefault: z.array(z.string()),
categoriaId: z.string().min(1, "Categoria es requerida"), categoriaId: z.string().min(1, "Categoria es requerida"),
variante: z.string().nullable(), variante: z.string().nullable(),
nivel: z.string().nullable().optional(),
orden: z.number().int().min(0), orden: z.number().int().min(0),
}); });
+30
View File
@@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { MetaOpcion } from "./calculators";
export interface ServicioSeleccionado { export interface ServicioSeleccionado {
catalogoId: string; catalogoId: string;
@@ -9,6 +10,18 @@ export interface ServicioSeleccionado {
tiempoEntrega: string; tiempoEntrega: string;
entregables: string[]; entregables: string[];
notas?: string; notas?: string;
// Doble propuesta: "1" | "2" | "ambas". undefined = cotizacion normal.
opcion?: string;
// Partidas personalizadas (sin catalogo). modeloCobro define como se calcula el precio:
// - "fijo": precio editable / precioBase (servicios de catalogo)
// - "horas": precio = horas * tarifaHora
// - "retainer": precio = montoMinimo (mensual); tarifaHora y horasIncluidas son informativos
esPersonalizado?: boolean;
horas?: number;
tarifaHora?: number;
modeloCobro?: string;
montoMinimo?: number;
horasIncluidas?: number;
} }
export interface CotizacionDraft { export interface CotizacionDraft {
@@ -29,6 +42,9 @@ export interface CotizacionDraft {
planBucefaloNivel: string | null; planBucefaloNivel: string | null;
servicios: ServicioSeleccionado[]; servicios: ServicioSeleccionado[];
observaciones: string; observaciones: string;
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
esDoble: boolean;
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
} }
interface CotizacionStore { interface CotizacionStore {
@@ -41,6 +57,7 @@ interface CotizacionStore {
updateServicio: (catalogoId: string, updates: Partial<ServicioSeleccionado>) => void; updateServicio: (catalogoId: string, updates: Partial<ServicioSeleccionado>) => void;
removeServicio: (catalogoId: string) => void; removeServicio: (catalogoId: string) => void;
setServicios: (servicios: ServicioSeleccionado[]) => void; setServicios: (servicios: ServicioSeleccionado[]) => void;
updateOpcionMeta: (opcion: "1" | "2", parcial: Partial<MetaOpcion>) => void;
resetDraft: () => void; resetDraft: () => void;
} }
@@ -62,6 +79,8 @@ const initialDraft: CotizacionDraft = {
planBucefaloNivel: null, planBucefaloNivel: null,
servicios: [], servicios: [],
observaciones: "", observaciones: "",
esDoble: false,
opciones: {},
}; };
export const useCotizacionStore = create<CotizacionStore>((set) => ({ export const useCotizacionStore = create<CotizacionStore>((set) => ({
@@ -116,5 +135,16 @@ export const useCotizacionStore = create<CotizacionStore>((set) => ({
setServicios: (servicios) => setServicios: (servicios) =>
set((state) => ({ draft: { ...state.draft, servicios } })), set((state) => ({ draft: { ...state.draft, servicios } })),
updateOpcionMeta: (opcion, parcial) =>
set((state) => ({
draft: {
...state.draft,
opciones: {
...state.draft.opciones,
[opcion]: { ...state.draft.opciones[opcion], ...parcial },
},
},
})),
resetDraft: () => set({ draft: { ...initialDraft } }), resetDraft: () => set({ draft: { ...initialDraft } }),
})); }));