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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ TOOLS = [
|
|||||||
"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"],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 Entregable 2 ..." className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-none font-mono text-xs" />
|
<textarea value={form.entregablesDefault} onChange={(e) => update("entregablesDefault", e.target.value)} rows={4} placeholder="Entregable 1 Entregable 2 ..." className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-none 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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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 } }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"`,
|
||||||
|
|||||||
@@ -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"`,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 } }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user