1
This commit is contained in:
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
@@ -1,5 +1,71 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
# AGENTS.md — Cotizador E3
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
## Quick Reference
|
||||||
<!-- END:nextjs-agent-rules -->
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `npm run dev` | Start Next.js dev server on port 3000 |
|
||||||
|
| `npm run build` | Production build (runs TypeScript check) |
|
||||||
|
| `npm run lint` | ESLint (flat config, eslint-config-next) |
|
||||||
|
| `npx tsx prisma/seed.ts` | Run seed (upserts all data, idempotent) |
|
||||||
|
| `npx prisma migrate dev` | Create/apply migration |
|
||||||
|
| `npx prisma generate` | Regenerate Prisma client |
|
||||||
|
| `npx prisma studio` | Prisma Studio GUI |
|
||||||
|
|
||||||
|
**Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`.
|
||||||
|
|
||||||
|
**Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`.
|
||||||
|
|
||||||
|
## Prisma 7 — Critical Gotchas
|
||||||
|
|
||||||
|
- **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`.
|
||||||
|
- **Uses `PrismaPg` driver adapter** (from `@prisma/adapter-pg`) with individual connection params (host/port/user/password/database), NOT a connection string. The `connectionString` approach causes SCRAM auth errors with postgres:postgres.
|
||||||
|
- **`prisma.config.ts`** has no `seed` property (unsupported by PrismaConfig type). Run seed manually with `npx tsx prisma/seed.ts`.
|
||||||
|
- After changing `schema.prisma`: run `npx prisma migrate dev`, then `npx prisma generate`.
|
||||||
|
- The `globalForPrisma` singleton pattern in `src/lib/db.ts` prevents hot-reload connection leaks in dev.
|
||||||
|
|
||||||
|
## PDFKit — Server-Side PDF
|
||||||
|
|
||||||
|
- `pdfkit` is excluded from Next.js bundling via `serverExternalPackages` in `next.config.ts`. Without this, font metric files (`.afm`) can't be resolved at runtime.
|
||||||
|
- **Footer auto-page-break bug:** `doc.text()` at `y > page.height - margins.bottom` triggers pdfkit's automatic page addition. To write footers in the bottom margin, temporarily set `doc.page.margins.bottom = 0`, write, then restore. Use `bufferPages: true` + `switchToPage()` to add footers after all content.
|
||||||
|
- Returns `Promise<Buffer>` — convert to `Uint8Array` for `NextResponse` body.
|
||||||
|
|
||||||
|
## Next.js 16 Conventions
|
||||||
|
|
||||||
|
- **Async params:** Dynamic route params are `Promise<{ id: string }>` — must `await params` before use.
|
||||||
|
- **All pages are `force-dynamic`** — no static generation or ISR.
|
||||||
|
- **Tailwind CSS v4** — no `tailwind.config.*` file. Config is in `postcss.config.mjs` (using `@tailwindcss/postcss`) and CSS custom properties via `@theme inline` in `src/app/globals.css`.
|
||||||
|
|
||||||
|
## Data Model Notes
|
||||||
|
|
||||||
|
- **Configuracion** is a key-value store (`clave`/`valor`), not a traditional settings model. New config keys: `color_primario`, `color_secundario`, `logo_base64` (branding for PDF export).
|
||||||
|
- **Cascading deletes:** Cotizacion → ServicioCotizado, PlanBucefaloCotizacion use `onDelete: Cascade`.
|
||||||
|
- Cotización number format: `UJ{YY}{MM}{AsesorInitials}{seq}` (e.g. `UJ2605AG001`) — see `generarNumeroCotizacion` in `src/lib/calculators.ts`. Sequence is 3 digits, zero-padded.
|
||||||
|
- Vigencia = 15 business days from fecha (excludes Sat/Sun) — `calcularVigencia`.
|
||||||
|
- IVA_RATE = 0.16 (16%).
|
||||||
|
- **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly).
|
||||||
|
- **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
app/ # Next.js App Router pages + API routes
|
||||||
|
api/ # Route handlers (REST endpoints)
|
||||||
|
cotizaciones/[id]/editar/ # Edit mode (reuses CotizacionEditor via EditorLoader)
|
||||||
|
components/ # Shared client components (ExportButtons, Sidebar)
|
||||||
|
lib/ # Utilities: db.ts, store.ts, calculators.ts, pdf-generator.ts
|
||||||
|
generated/prisma/ # Prisma client output (gitignored)
|
||||||
|
prisma/
|
||||||
|
schema.prisma # 8 models
|
||||||
|
seed.ts # All catalog data (23 services, bonos, planes, config)
|
||||||
|
migrations/ # Prisma migration files
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Zustand store** (`src/lib/store.ts`) manages cotización editor state (draft form). Used by both new and edit pages.
|
||||||
|
- **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body).
|
||||||
|
- The old standalone `ExportExcelButton.tsx` was deleted — all exports now go through `ExportButtons.tsx`.
|
||||||
|
|
||||||
|
## Domain
|
||||||
|
|
||||||
|
Quotation system for Consultoría E3 (digital marketing agency in Querétaro, MX). Services organized in 4 phases: Fase 0 (Auditorías), Fase 1 (Setup/Infra), Fase 2 (Publicidad/Manejo), Fase 3 (Contenido/SEO). Two payment types: `unico` (one-time) and `mensual` (recurring). Optional Bucéfalo CRM plans and Openpay financing.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,470 @@
|
|||||||
|
# Cotizador E3 — API Documentation for AI Skills
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
REST API for Consultoría E3's digital marketing quotation system. Allows AI agents to manage clients, browse service catalogs, create quotations, calculate financing, generate PDF/Excel documents, and manage the full sales pipeline.
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:8000`
|
||||||
|
**Auth:** API Key via `X-API-Key` header or `Authorization: Bearer <key>`
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
**OpenAPI Spec:** `GET /openapi.json` (no auth required)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain Context
|
||||||
|
|
||||||
|
Consultoría E3 is a digital marketing agency in Querétaro, México. Services are organized in 4 project phases:
|
||||||
|
|
||||||
|
- **Fase 0 — Auditoría / Acompañamiento:** Initial assessments and ongoing consulting
|
||||||
|
- **Fase 1 — Setup e Infraestructura:** One-time setup (websites, branding, CRM config)
|
||||||
|
- **Fase 2 — Publicidad y Manejo:** Ongoing advertising and social media management
|
||||||
|
- **Fase 3 — Contenido y SEO:** Content creation and search engine optimization
|
||||||
|
|
||||||
|
Two payment types:
|
||||||
|
- **unico:** One-time payment
|
||||||
|
- **mensual:** Monthly recurring payment
|
||||||
|
|
||||||
|
Currency: MXN (Mexican Pesos) or USD. IVA (tax) rate: 16%.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### API Key (for agents)
|
||||||
|
```
|
||||||
|
X-API-Key: your-api-key-here
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```
|
||||||
|
Authorization: Bearer your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### JWT (for human login)
|
||||||
|
```
|
||||||
|
POST /auth/login
|
||||||
|
Body: { "email": "[email protected]", "password": "secret" }
|
||||||
|
Response: { "user": { "id", "name", "email", "role" } }
|
||||||
|
Sets cookie: cotizador-session (httpOnly, 7 days)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints Reference
|
||||||
|
|
||||||
|
### Health & Info
|
||||||
|
|
||||||
|
#### GET /health
|
||||||
|
Health check endpoint. No auth required.
|
||||||
|
- **Response:** `{ "status": "ok", "timestamp": "2026-05-03T..." }`
|
||||||
|
|
||||||
|
#### GET /api-info
|
||||||
|
API capabilities and version info.
|
||||||
|
- **Response:** `{ "name": "Cotizador E3 API", "version": "1.0.0", "capabilities": [...] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Clients (Clientes)
|
||||||
|
|
||||||
|
#### GET /clientes
|
||||||
|
List clients with optional search and pagination.
|
||||||
|
- **Query params:** `?q=texto&page=1&limit=50`
|
||||||
|
- **Response:** `{ "data": [Cliente], "meta": { "total", "page", "limit", "pages" } }`
|
||||||
|
|
||||||
|
#### POST /clientes
|
||||||
|
Create a new client.
|
||||||
|
- **Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre": "Juan Pérez",
|
||||||
|
"empresa": "ACME Corp",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"telefono": "4421234567"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (201):** Full Cliente object with `id`
|
||||||
|
|
||||||
|
#### GET /clientes/{id}
|
||||||
|
Get client details including recent quotations.
|
||||||
|
- **Response:** Cliente object + `cotizaciones` array
|
||||||
|
|
||||||
|
#### PUT /clientes/{id}
|
||||||
|
Update client information.
|
||||||
|
- **Body:** Same as POST (all fields optional for update)
|
||||||
|
|
||||||
|
#### DELETE /clientes/{id}
|
||||||
|
Delete client. Returns 409 if client has quotations.
|
||||||
|
- **Response (200):** `{ "ok": true }`
|
||||||
|
- **Error (409):** `{ "error": "Cliente tiene N cotizaciones asociadas" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Service Catalog (Catálogo)
|
||||||
|
|
||||||
|
#### GET /catalogo
|
||||||
|
List all active services with optional filters.
|
||||||
|
- **Query params:** `?fase=0&tipoPago=mensual&categoriaId=xxx&q=seo`
|
||||||
|
- **Response:** Array of ServicioCatalogo objects
|
||||||
|
|
||||||
|
Each service:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "cuid",
|
||||||
|
"nombre": "Posicionamiento SEO",
|
||||||
|
"descripcion": "...",
|
||||||
|
"fase": 3,
|
||||||
|
"tipoPago": "mensual",
|
||||||
|
"precioBase": 2900,
|
||||||
|
"tiempoEntrega": "7 - 14 dias",
|
||||||
|
"entregablesDefault": ["Keyword research", "On-page optimization"],
|
||||||
|
"categoriaId": "cuid",
|
||||||
|
"variante": null,
|
||||||
|
"activo": true,
|
||||||
|
"orden": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /catalogo
|
||||||
|
Create a new service.
|
||||||
|
- **Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre": "Nuevo Servicio",
|
||||||
|
"descripcion": "Descripción del servicio",
|
||||||
|
"fase": 1,
|
||||||
|
"tipoPago": "unico",
|
||||||
|
"precioBase": 5000,
|
||||||
|
"tiempoEntrega": "7 - 14 dias",
|
||||||
|
"entregablesDefault": ["Entregable 1", "Entregable 2"],
|
||||||
|
"categoriaId": "cuid",
|
||||||
|
"variante": null,
|
||||||
|
"orden": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /catalogo/{id}
|
||||||
|
Get service details with category info.
|
||||||
|
|
||||||
|
#### PUT /catalogo/{id}
|
||||||
|
Update service. Same body as POST.
|
||||||
|
|
||||||
|
#### DELETE /catalogo/{id}
|
||||||
|
Soft-delete if used in quotations, hard-delete otherwise.
|
||||||
|
- **Response:** `{ "ok": true, "archived": true|false }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Categories (Categorías)
|
||||||
|
|
||||||
|
Available: SEO (#10b981), Marketing (#6366f1), Paid Media (#f59e0b), Desarrollo Web (#3b82f6), Automatizaciones (#8b5cf6), CRM (#ec4899), Desarrollo Personalizado (#14b8a6)
|
||||||
|
|
||||||
|
#### GET /categorias
|
||||||
|
List all categories ordered by `orden`.
|
||||||
|
|
||||||
|
#### POST /categorias
|
||||||
|
- **Body:** `{ "nombre": "Nueva Cat", "descripcion": "...", "color": "#6b7280", "orden": 0 }`
|
||||||
|
|
||||||
|
#### GET /categorias/{id}
|
||||||
|
Get category with its services.
|
||||||
|
|
||||||
|
#### PUT /categorias/{id}
|
||||||
|
- **Body:** `{ "nombre", "descripcion", "color", "orden", "activo" }`
|
||||||
|
|
||||||
|
#### DELETE /categorias/{id}
|
||||||
|
Returns 409 if category has associated services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Quotations (Cotizaciones)
|
||||||
|
|
||||||
|
#### GET /cotizaciones
|
||||||
|
List quotations with filters.
|
||||||
|
- **Query params:** `?estado=borrador&asesorId=xxx&clienteId=xxx&q=texto&desde=2026-01-01&hasta=2026-12-31&page=1&limit=50`
|
||||||
|
- **Response:** Array with full relations (cliente, asesor, servicios+catalogo, planBucefalo)
|
||||||
|
|
||||||
|
#### POST /cotizaciones
|
||||||
|
Create a complete quotation. Auto-creates client if not found.
|
||||||
|
- **Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"numero": "UJ2605AG001",
|
||||||
|
"fecha": "2026-05-03",
|
||||||
|
"vigencia": "2026-05-24",
|
||||||
|
"moneda": "MXN",
|
||||||
|
"tipoCambio": "NA",
|
||||||
|
"proyecto": "MKT Digital",
|
||||||
|
"esquemaPago": "Pago Unico/Mensual",
|
||||||
|
"incluirBonos": false,
|
||||||
|
"incluirFinanciamiento": false,
|
||||||
|
"observaciones": "",
|
||||||
|
"asesorId": "cuid",
|
||||||
|
"cliente": {
|
||||||
|
"nombre": "Juan Pérez",
|
||||||
|
"empresa": "ACME Corp",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"telefono": "4421234567"
|
||||||
|
},
|
||||||
|
"servicios": [
|
||||||
|
{
|
||||||
|
"catalogoId": "cuid",
|
||||||
|
"nombre": "SEO On-Page",
|
||||||
|
"fase": 3,
|
||||||
|
"tipoPago": "mensual",
|
||||||
|
"precio": 2900,
|
||||||
|
"tiempoEntrega": "7 - 14 dias",
|
||||||
|
"entregables": ["Keyword research", "On-page SEO"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"planBucefalo": {
|
||||||
|
"nivel": "basico",
|
||||||
|
"precio": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /cotizaciones/{id}
|
||||||
|
Get quotation with all relations.
|
||||||
|
|
||||||
|
#### PUT /cotizaciones/{id}
|
||||||
|
Update quotation. Replaces all services in a transaction.
|
||||||
|
- **Body:** Same as POST without `numero` and `asesorId`. Optional `estado`.
|
||||||
|
|
||||||
|
#### DELETE /cotizaciones/{id}
|
||||||
|
Hard-delete with cascade.
|
||||||
|
|
||||||
|
#### PATCH /cotizaciones/{id}/precio
|
||||||
|
Update price of a single service.
|
||||||
|
- **Body:** `{ "servicioId": "cuid", "precio": 7500 }`
|
||||||
|
|
||||||
|
#### PATCH /cotizaciones/{id}/estado
|
||||||
|
Change quotation status.
|
||||||
|
- **Body:** `{ "estado": "enviada" }` — `borrador`, `enviada`, `aprobada`, `rechazada`
|
||||||
|
|
||||||
|
#### POST /cotizaciones/{id}/duplicate
|
||||||
|
Duplicate quotation as new copy in `borrador` state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Packages (Paquetes)
|
||||||
|
|
||||||
|
#### GET /paquetes
|
||||||
|
List active packages with nested phases and services.
|
||||||
|
|
||||||
|
#### POST /paquetes
|
||||||
|
- **Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre": "Paquete Básico",
|
||||||
|
"descripcion": "...",
|
||||||
|
"fases": [
|
||||||
|
{ "nombre": "Auditoría", "orden": 0 },
|
||||||
|
{ "nombre": "Setup", "orden": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /paquetes/{id}
|
||||||
|
Get package with full nested structure.
|
||||||
|
|
||||||
|
#### PUT /paquetes/{id}
|
||||||
|
- **Body:** `{ "nombre", "descripcion", "activo" }`
|
||||||
|
|
||||||
|
#### DELETE /paquetes/{id}
|
||||||
|
Hard-delete with cascade.
|
||||||
|
|
||||||
|
#### POST /paquetes/{id}/manage
|
||||||
|
Multi-action endpoint:
|
||||||
|
- `{ "action": "addFase", "nombre": "Fase 3", "orden": 2 }`
|
||||||
|
- `{ "action": "updateFase", "faseId": "cuid", "nombre": "New Name", "orden": 1 }`
|
||||||
|
- `{ "action": "deleteFase", "faseId": "cuid" }`
|
||||||
|
- `{ "action": "addServicio", "servicioCatalogoId": "cuid", "fasePaqueteId": "cuid" }`
|
||||||
|
- `{ "action": "removeServicio", "servicioCatalogoId": "cuid", "fasePaqueteId": "cuid" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Configuration (Configuración)
|
||||||
|
|
||||||
|
Allowed keys: `color_primario`, `color_secundario`, `logo_base64`, `razon_social`, `rfc`, `domicilio_fiscal`, `cuenta_nacional`, `clabe_interbancaria`, `cuenta_internacional`, `cuenta_internacional_swift`, `hora_centinela`, `anualidad_hosting`, `iva`, `terminos_condiciones`, `no_incluye`, `notas_adicionales`
|
||||||
|
|
||||||
|
#### GET /configuracion
|
||||||
|
- **Response:** `{ "color_primario": "#2563eb", "razon_social": "...", ... }`
|
||||||
|
|
||||||
|
#### PUT /configuracion
|
||||||
|
Bulk upsert. Only whitelisted keys processed.
|
||||||
|
- **Body:** `{ "color_primario": "#ff0000", "razon_social": "Nuevo Nombre" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Bonuses (Bonos)
|
||||||
|
|
||||||
|
Available: Centinela Web, Workshop Buyer Persona, Workshop Propuesta de Valor, Membresía Premium, Mes Gratis CRM, Script de Ventas
|
||||||
|
|
||||||
|
#### GET /bonos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Financing (Financiamiento)
|
||||||
|
|
||||||
|
| Months | Rate | Commission | Min Amount |
|
||||||
|
|--------|------|------------|------------|
|
||||||
|
| 3 | 7.7% | 2.5% | $300 |
|
||||||
|
| 6 | 10.7% | 2.5% | $600 |
|
||||||
|
| 9 | 13.7% | 2.5% | $900 |
|
||||||
|
| 12 | 16.7% | 2.5% | $1,200 |
|
||||||
|
|
||||||
|
#### GET /financiamiento/planes
|
||||||
|
|
||||||
|
#### POST /financiamiento/calcular
|
||||||
|
- **Body:** `{ "monto": 50000, "meses": 6 }`
|
||||||
|
- **Response:** `{ "pagoMensual", "ivaMensual", "totalMensual", "comisionTotal", "granTotal" }`
|
||||||
|
|
||||||
|
#### GET /financiamiento/simulacion/{cotizacion_id}
|
||||||
|
Auto-calculate financing for existing quotation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
#### POST /export/pdf — PDF from draft data
|
||||||
|
#### GET /export/pdf/{id} — PDF from saved quotation
|
||||||
|
#### POST /export/excel — Excel from draft data
|
||||||
|
#### GET /export/excel/{id} — Excel from saved quotation
|
||||||
|
#### GET /export/catalogo — CSV of active services
|
||||||
|
#### GET /export/catalogo/plantilla — CSV import template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Import
|
||||||
|
|
||||||
|
#### POST /import/catalogo
|
||||||
|
Bulk import services from CSV. `multipart/form-data` with `file` field.
|
||||||
|
- **Response:** `{ "creados": 5, "omitidos": 2, "errores": [...] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Rules
|
||||||
|
|
||||||
|
### Quotation Number
|
||||||
|
Format: `UJ{YY}{MM}{AsesorInitials}{sequence}` — e.g., `UJ2605AG001`
|
||||||
|
|
||||||
|
### Quotation Validity
|
||||||
|
15 business days from issue (excludes Sat/Sun).
|
||||||
|
|
||||||
|
### CRM Bucefalo Plans
|
||||||
|
basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes
|
||||||
|
|
||||||
|
### Client Deduplication
|
||||||
|
Matched by `(nombre, empresa)` pair.
|
||||||
|
|
||||||
|
### Service Soft-Delete
|
||||||
|
If referenced by quotations → `activo=false`. Otherwise hard-delete.
|
||||||
|
|
||||||
|
### Financing Formula
|
||||||
|
```
|
||||||
|
comisionTotal = monto × comision%
|
||||||
|
montoConComision = monto + comisionTotal
|
||||||
|
pagoMensual = montoConComision × (1 + tasa) / meses
|
||||||
|
ivaMensual = pagoMensual × 0.16
|
||||||
|
totalMensual = pagoMensual + ivaMensual
|
||||||
|
granTotal = totalMensual × meses
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Responses
|
||||||
|
|
||||||
|
### Success (paginated)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [...],
|
||||||
|
"meta": { "total": 45, "page": 1, "limit": 50, "pages": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error
|
||||||
|
```json
|
||||||
|
{ "error": "Message", "detail": "Details", "code": "ERROR_CODE" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Codes
|
||||||
|
200=OK, 201=Created, 204=No Content, 400=Bad Request, 401=Unauthorized, 404=Not Found, 409=Conflict, 500=Server Error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Workflows
|
||||||
|
|
||||||
|
### Create quotation
|
||||||
|
```
|
||||||
|
1. GET /catalogo?fase=0 → audit services
|
||||||
|
2. GET /catalogo?fase=1 → setup services
|
||||||
|
3. GET /catalogo?fase=2&tipoPago=mensual → ongoing services
|
||||||
|
4. POST /financiamiento/calcular → simulate payments
|
||||||
|
5. POST /cotizaciones → create quotation
|
||||||
|
6. GET /export/pdf/{id} → generate PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review pipeline
|
||||||
|
```
|
||||||
|
1. GET /cotizaciones?estado=borrador → pending
|
||||||
|
2. GET /cotizaciones?estado=enviada → awaiting
|
||||||
|
3. GET /cotizaciones?estado=aprobada → won
|
||||||
|
```
|
||||||
|
|
||||||
|
### Negotiate price
|
||||||
|
```
|
||||||
|
1. GET /cotizaciones/{id} → current state
|
||||||
|
2. PATCH /cotizaciones/{id}/precio → adjust price
|
||||||
|
3. GET /export/pdf/{id} → regenerate PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Integration
|
||||||
|
|
||||||
|
MCP server at `/mcp` for OpenClaw, Claude, ChatGPT.
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `buscar_servicios` | Search catalog by phase, payment type, category, text |
|
||||||
|
| `crear_cotizacion` | Create complete quotation in one call |
|
||||||
|
| `obtener_cotizacion` | Get quotation details |
|
||||||
|
| `listar_cotizaciones` | List with filters |
|
||||||
|
| `cambiar_estado_cotizacion` | Change status |
|
||||||
|
| `actualizar_precio_servicio` | Adjust service price |
|
||||||
|
| `duplicar_cotizacion` | Clone as draft |
|
||||||
|
| `calcular_financiamiento` | Calculate payments |
|
||||||
|
| `generar_pdf_cotizacion` | Generate PDF |
|
||||||
|
| `obtener_configuracion` | Company config |
|
||||||
|
| `listar_bonos` | Available bonuses |
|
||||||
|
| `listar_planes_bucefalo` | CRM plans |
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
| Resource | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `cotizador://servicios` | Full catalog |
|
||||||
|
| `cotizador://categorias` | Categories |
|
||||||
|
| `cotizador://configuracion` | Company config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_NAME=cotizador_e3
|
||||||
|
API_KEY=your-secret-api-key
|
||||||
|
JWT_SECRET=your-jwt-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
# Swagger: http://localhost:8000/docs
|
||||||
|
# ReDoc: http://localhost:8000/redoc
|
||||||
|
# OpenAPI: http://localhost:8000/openapi.json
|
||||||
|
```
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException, status
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return pwd_context.verify(plain, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||||
|
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
|
||||||
|
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.JWT_SECRET, algorithms=[ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_api_key(x_api_key: str | None = Header(None)) -> str | None:
|
||||||
|
if x_api_key and x_api_key == settings.API_KEY:
|
||||||
|
return x_api_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
authorization: str | None = Header(None),
|
||||||
|
x_api_key: str | None = Header(None),
|
||||||
|
) -> dict:
|
||||||
|
if x_api_key and x_api_key == settings.API_KEY:
|
||||||
|
return {"auth_type": "api_key"}
|
||||||
|
|
||||||
|
if authorization:
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "bearer" and token:
|
||||||
|
payload = decode_token(token)
|
||||||
|
if payload:
|
||||||
|
return {"auth_type": "jwt", **payload}
|
||||||
|
if x_api_key == settings.API_KEY:
|
||||||
|
return {"auth_type": "api_key"}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or missing authentication credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DB_HOST: str = "localhost"
|
||||||
|
DB_PORT: int = 5432
|
||||||
|
DB_USER: str = "postgres"
|
||||||
|
DB_PASSWORD: str = "postgres"
|
||||||
|
DB_NAME: str = "cotizador_e3"
|
||||||
|
API_KEY: str = "change-me-to-a-secure-random-key"
|
||||||
|
JWT_SECRET: str = "change-me-to-a-secure-jwt-secret"
|
||||||
|
API_HOST: str = "0.0.0.0"
|
||||||
|
API_PORT: int = 8000
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
global pool
|
||||||
|
pool = await asyncpg.create_pool(
|
||||||
|
host=settings.DB_HOST,
|
||||||
|
port=settings.DB_PORT,
|
||||||
|
user=settings.DB_USER,
|
||||||
|
password=settings.DB_PASSWORD,
|
||||||
|
database=settings.DB_NAME,
|
||||||
|
min_size=2,
|
||||||
|
max_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db() -> None:
|
||||||
|
global pool
|
||||||
|
if pool:
|
||||||
|
await pool.close()
|
||||||
|
pool = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_pool() -> asyncpg.Pool:
|
||||||
|
if pool is None:
|
||||||
|
raise RuntimeError("Database pool not initialized. Call init_db() first.")
|
||||||
|
return pool
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Query
|
||||||
|
|
||||||
|
from app.database import get_pool
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationParams:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
|
):
|
||||||
|
self.page = page
|
||||||
|
self.limit = limit
|
||||||
|
self.offset = (page - 1) * limit
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str: str | None):
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
from datetime import datetime
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,446 @@
|
|||||||
|
"""MCP Server for the Cotizador E3 API.
|
||||||
|
|
||||||
|
Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.)
|
||||||
|
to interact with the quotation system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.types import Resource, TextContent, Tool
|
||||||
|
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.mcp.tools import RESOURCES, TOOLS
|
||||||
|
from app.services.calculators import (
|
||||||
|
BONOS,
|
||||||
|
PLANES_BUCEFALO,
|
||||||
|
calcular_financiamiento,
|
||||||
|
bucefalo_precio,
|
||||||
|
)
|
||||||
|
|
||||||
|
server = Server("cotizador-e3")
|
||||||
|
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name=t["name"],
|
||||||
|
description=t["description"],
|
||||||
|
inputSchema=t["inputSchema"],
|
||||||
|
)
|
||||||
|
for t in TOOLS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@server.list_resources()
|
||||||
|
async def list_resources() -> list[Resource]:
|
||||||
|
return [
|
||||||
|
Resource(
|
||||||
|
uri=r["uri"],
|
||||||
|
name=r["name"],
|
||||||
|
description=r["description"],
|
||||||
|
mimeType=r["mimeType"],
|
||||||
|
)
|
||||||
|
for r in RESOURCES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@server.read_resource()
|
||||||
|
async def read_resource(uri: str) -> str:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if uri == "cotizador://servicios":
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
||||||
|
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
||||||
|
s.variante, s.activo, s.orden,
|
||||||
|
c.nombre as categoria_nombre
|
||||||
|
FROM "ServicioCatalogo" s
|
||||||
|
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
|
||||||
|
WHERE s.activo = true
|
||||||
|
ORDER BY s.fase ASC, s.orden ASC"""
|
||||||
|
)
|
||||||
|
return json.dumps([dict(r) for r in rows], default=str)
|
||||||
|
|
||||||
|
elif uri == "cotizador://categorias":
|
||||||
|
rows = await conn.fetch(
|
||||||
|
'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC'
|
||||||
|
)
|
||||||
|
return json.dumps([dict(r) for r in rows], default=str)
|
||||||
|
|
||||||
|
elif uri == "cotizador://configuracion":
|
||||||
|
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
|
||||||
|
return json.dumps({r["clave"]: r["valor"] for r in rows})
|
||||||
|
|
||||||
|
return json.dumps({"error": f"Unknown resource: {uri}"})
|
||||||
|
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result = await _handle_tool(conn, name, arguments)
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_tool(conn, name: str, arguments: dict) -> dict:
|
||||||
|
if name == "buscar_servicios":
|
||||||
|
return await _buscar_servicios(conn, arguments)
|
||||||
|
elif name == "crear_cotizacion":
|
||||||
|
return await _crear_cotizacion(conn, arguments)
|
||||||
|
elif name == "obtener_cotizacion":
|
||||||
|
return await _obtener_cotizacion(conn, arguments)
|
||||||
|
elif name == "listar_cotizaciones":
|
||||||
|
return await _listar_cotizaciones(conn, arguments)
|
||||||
|
elif name == "cambiar_estado_cotizacion":
|
||||||
|
return await _cambiar_estado(conn, arguments)
|
||||||
|
elif name == "actualizar_precio_servicio":
|
||||||
|
return await _actualizar_precio(conn, arguments)
|
||||||
|
elif name == "duplicar_cotizacion":
|
||||||
|
return await _duplicar_cotizacion(conn, arguments)
|
||||||
|
elif name == "calcular_financiamiento":
|
||||||
|
return await _calcular_financiamiento(arguments)
|
||||||
|
elif name == "generar_pdf_cotizacion":
|
||||||
|
return await _generar_pdf(conn, arguments)
|
||||||
|
elif name == "obtener_configuracion":
|
||||||
|
return await _obtener_configuracion(conn)
|
||||||
|
elif name == "listar_bonos":
|
||||||
|
return {"bonos": BONOS}
|
||||||
|
elif name == "listar_planes_bucefalo":
|
||||||
|
return {"planes": PLANES_BUCEFALO}
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown tool: {name}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _buscar_servicios(conn, args: dict) -> dict:
|
||||||
|
conditions = ['s.activo = true']
|
||||||
|
params = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if args.get("fase") is not None:
|
||||||
|
conditions.append(f's.fase = ${idx}')
|
||||||
|
params.append(args["fase"])
|
||||||
|
idx += 1
|
||||||
|
if args.get("tipo_pago"):
|
||||||
|
conditions.append(f's."tipoPago" = ${idx}')
|
||||||
|
params.append(args["tipo_pago"])
|
||||||
|
idx += 1
|
||||||
|
if args.get("categoria"):
|
||||||
|
conditions.append(f'LOWER(c.nombre) = LOWER(${idx})')
|
||||||
|
params.append(args["categoria"])
|
||||||
|
idx += 1
|
||||||
|
if args.get("busqueda"):
|
||||||
|
conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))')
|
||||||
|
params.append(f"%{args['busqueda']}%")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
||||||
|
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
||||||
|
s.variante, c.nombre as categoria
|
||||||
|
FROM "ServicioCatalogo" s
|
||||||
|
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY s.fase ASC, s.orden ASC"""
|
||||||
|
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
servicios = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str):
|
||||||
|
try:
|
||||||
|
d["entregablesDefault"] = json.loads(d["entregablesDefault"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
servicios.append(d)
|
||||||
|
|
||||||
|
return {"servicios": servicios, "total": len(servicios)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _crear_cotizacion(conn, args: dict) -> dict:
|
||||||
|
cliente_data = args["cliente"]
|
||||||
|
servicios_data = args["servicios"]
|
||||||
|
plan_bucefalo = args.get("plan_bucefalo")
|
||||||
|
moneda = args.get("moneda", "MXN")
|
||||||
|
proyecto = args.get("proyecto", "MKT Digital")
|
||||||
|
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
|
||||||
|
|
||||||
|
cliente = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
|
||||||
|
cliente_data["nombre"],
|
||||||
|
cliente_data.get("empresa", ""),
|
||||||
|
)
|
||||||
|
if not cliente:
|
||||||
|
cliente = await conn.fetchrow(
|
||||||
|
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id',
|
||||||
|
cliente_data["nombre"],
|
||||||
|
cliente_data.get("empresa", ""),
|
||||||
|
cliente_data.get("email", ""),
|
||||||
|
cliente_data.get("telefono", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
cliente_id = cliente["id"]
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001"
|
||||||
|
|
||||||
|
from app.services.calculators import calcular_vigencia
|
||||||
|
vigencia = calcular_vigencia(now)
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
cot = await conn.fetchrow(
|
||||||
|
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
||||||
|
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, '', $7, $8, NOW(), NOW())
|
||||||
|
RETURNING id, numero""",
|
||||||
|
numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent",
|
||||||
|
)
|
||||||
|
cot_id = cot["id"]
|
||||||
|
|
||||||
|
for srv in servicios_data:
|
||||||
|
catalogo_id = srv["servicio_id"]
|
||||||
|
cat_row = await conn.fetchrow(
|
||||||
|
'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1',
|
||||||
|
catalogo_id,
|
||||||
|
)
|
||||||
|
if not cat_row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
precio = srv.get("precio_personalizado") or cat_row["precioBase"]
|
||||||
|
entregables = cat_row["entregablesDefault"]
|
||||||
|
if isinstance(entregables, str):
|
||||||
|
try:
|
||||||
|
entregables = json.loads(entregables)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
entregables = []
|
||||||
|
|
||||||
|
await conn.fetchrow(
|
||||||
|
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
|
||||||
|
precio, "tiempoEntrega", entregables, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())""",
|
||||||
|
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
|
||||||
|
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []),
|
||||||
|
)
|
||||||
|
|
||||||
|
if plan_bucefalo:
|
||||||
|
nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico")
|
||||||
|
precio_bp = bucefalo_precio(nivel)
|
||||||
|
await conn.fetchrow(
|
||||||
|
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""",
|
||||||
|
cot_id, nivel, precio_bp,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _obtener_cotizacion(conn, args: dict) -> dict:
|
||||||
|
cot = await conn.fetchrow(
|
||||||
|
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
|
||||||
|
cl.email as cliente_email, cl.telefono as cliente_telefono
|
||||||
|
FROM "Cotizacion" c
|
||||||
|
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||||
|
WHERE c.id = $1""",
|
||||||
|
args["cotizacion_id"],
|
||||||
|
)
|
||||||
|
if not cot:
|
||||||
|
return {"error": "Cotización no encontrada"}
|
||||||
|
|
||||||
|
servicios = await conn.fetch(
|
||||||
|
"""SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago"
|
||||||
|
FROM "ServicioCotizado" sc
|
||||||
|
LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id
|
||||||
|
WHERE sc."cotizacionId" = $1""",
|
||||||
|
args["cotizacion_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = await conn.fetchrow(
|
||||||
|
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||||
|
args["cotizacion_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(cot)
|
||||||
|
result["cliente"] = {
|
||||||
|
"nombre": cot["cliente_nombre"],
|
||||||
|
"empresa": cot["cliente_empresa"],
|
||||||
|
"email": cot["cliente_email"],
|
||||||
|
"telefono": cot["cliente_telefono"],
|
||||||
|
}
|
||||||
|
result["servicios"] = [dict(s) for s in servicios]
|
||||||
|
result["planBucefalo"] = dict(plan) if plan else None
|
||||||
|
|
||||||
|
for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]:
|
||||||
|
result.pop(k, None)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _listar_cotizaciones(conn, args: dict) -> dict:
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if args.get("estado"):
|
||||||
|
conditions.append(f'c.estado = ${idx}')
|
||||||
|
params.append(args["estado"])
|
||||||
|
idx += 1
|
||||||
|
if args.get("cliente_nombre"):
|
||||||
|
conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})')
|
||||||
|
params.append(f"%{args['cliente_nombre']}%")
|
||||||
|
idx += 1
|
||||||
|
if args.get("busqueda"):
|
||||||
|
conditions.append(
|
||||||
|
f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))'
|
||||||
|
)
|
||||||
|
params.append(f"%{args['busqueda']}%")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||||
|
query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto,
|
||||||
|
c.moneda, c."esquemaPago",
|
||||||
|
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
|
||||||
|
FROM "Cotizacion" c
|
||||||
|
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||||
|
{where}
|
||||||
|
ORDER BY c."createdAt" DESC
|
||||||
|
LIMIT 50"""
|
||||||
|
|
||||||
|
rows = await conn.fetch(query, *params)
|
||||||
|
cotizaciones = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")}
|
||||||
|
cotizaciones.append(d)
|
||||||
|
|
||||||
|
return {"cotizaciones": cotizaciones, "total": len(cotizaciones)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _cambiar_estado(conn, args: dict) -> dict:
|
||||||
|
cot_id = args["cotizacion_id"]
|
||||||
|
estado = args["estado"]
|
||||||
|
|
||||||
|
cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id)
|
||||||
|
if not cot:
|
||||||
|
return {"error": "Cotización no encontrada"}
|
||||||
|
|
||||||
|
await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id)
|
||||||
|
return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado}
|
||||||
|
|
||||||
|
|
||||||
|
async def _actualizar_precio(conn, args: dict) -> dict:
|
||||||
|
cot_id = args["cotizacion_id"]
|
||||||
|
servicio_id = args["servicio_id"]
|
||||||
|
nuevo_precio = args["nuevo_precio"]
|
||||||
|
|
||||||
|
srv = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
|
||||||
|
servicio_id, cot_id,
|
||||||
|
)
|
||||||
|
if not srv:
|
||||||
|
return {"error": "Servicio no encontrado en esta cotización"}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
|
||||||
|
nuevo_precio, servicio_id,
|
||||||
|
)
|
||||||
|
return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio}
|
||||||
|
|
||||||
|
|
||||||
|
async def _duplicar_cotizacion(conn, args: dict) -> dict:
|
||||||
|
cot_id = args["cotizacion_id"]
|
||||||
|
|
||||||
|
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
|
||||||
|
if not original:
|
||||||
|
return {"error": "Cotización no encontrada"}
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
new_numero = f"{original['numero']}-COPY"
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
new_cot = await conn.fetchrow(
|
||||||
|
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
||||||
|
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, NOW(), NOW())
|
||||||
|
RETURNING id, numero""",
|
||||||
|
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
|
||||||
|
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
|
||||||
|
original["incluirFinanciamiento"], original["observaciones"],
|
||||||
|
original["clienteId"], original["asesorId"],
|
||||||
|
)
|
||||||
|
new_id = new_cot["id"]
|
||||||
|
|
||||||
|
servicios = await conn.fetch(
|
||||||
|
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id
|
||||||
|
)
|
||||||
|
for s in servicios:
|
||||||
|
await conn.fetchrow(
|
||||||
|
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
|
||||||
|
precio, "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())""",
|
||||||
|
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
|
||||||
|
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"],
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = await conn.fetchrow(
|
||||||
|
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id
|
||||||
|
)
|
||||||
|
if plan:
|
||||||
|
await conn.fetchrow(
|
||||||
|
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""",
|
||||||
|
new_id, plan["nivel"], plan["precio"], plan["seleccionado"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _calcular_financiamiento(args: dict) -> dict:
|
||||||
|
monto = args["monto"]
|
||||||
|
meses = args["meses"]
|
||||||
|
|
||||||
|
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
|
||||||
|
if not plan:
|
||||||
|
from app.services.calculators import FINANCIAMIENTO_PLANES
|
||||||
|
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
|
||||||
|
|
||||||
|
if not plan:
|
||||||
|
return {"error": f"Plan de {meses} meses no disponible"}
|
||||||
|
|
||||||
|
result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"])
|
||||||
|
result["meses"] = meses
|
||||||
|
result["tasa"] = plan["tasa"]
|
||||||
|
result["comision"] = plan["comision"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _generar_pdf(conn, args: dict) -> dict:
|
||||||
|
cot_id = args["cotizacion_id"]
|
||||||
|
cot = await conn.fetchrow(
|
||||||
|
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
|
||||||
|
FROM "Cotizacion" c
|
||||||
|
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||||
|
WHERE c.id = $1""",
|
||||||
|
cot_id,
|
||||||
|
)
|
||||||
|
if not cot:
|
||||||
|
return {"error": "Cotización no encontrada"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "pdf_generated",
|
||||||
|
"cotizacion_id": cot_id,
|
||||||
|
"numero": cot["numero"],
|
||||||
|
"filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf",
|
||||||
|
"message": "PDF generation will be implemented with reportlab",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _obtener_configuracion(conn) -> dict:
|
||||||
|
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
|
||||||
|
return {"config": {r["clave"]: r["valor"] for r in rows}}
|
||||||
|
|
||||||
|
|
||||||
|
from app.services.calculators import FINANCIAMIENTO_PLANES
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""MCP (Model Context Protocol) tool definitions for the Cotizador API.
|
||||||
|
|
||||||
|
Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT.
|
||||||
|
"""
|
||||||
|
|
||||||
|
TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "buscar_servicios",
|
||||||
|
"description": (
|
||||||
|
"Busca servicios del catálogo de marketing digital de Consultoría E3. "
|
||||||
|
"Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. "
|
||||||
|
"Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), "
|
||||||
|
"2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). "
|
||||||
|
"Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fase": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [0, 1, 2, 3],
|
||||||
|
"description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO",
|
||||||
|
},
|
||||||
|
"tipo_pago": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["unico", "mensual"],
|
||||||
|
"description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente",
|
||||||
|
},
|
||||||
|
"categoria": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado",
|
||||||
|
},
|
||||||
|
"busqueda": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Texto libre para buscar en nombre y descripción del servicio",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "crear_cotizacion",
|
||||||
|
"description": (
|
||||||
|
"Crea una cotización completa de servicios de marketing digital para un cliente. "
|
||||||
|
"El cliente se crea automáticamente si no existe (busca por nombre+empresa). "
|
||||||
|
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
|
||||||
|
"y configuración de moneda y esquema de pago. "
|
||||||
|
"La cotización se crea en estado 'borrador'. "
|
||||||
|
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cliente": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"nombre": {"type": "string", "description": "Nombre completo del contacto"},
|
||||||
|
"empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"},
|
||||||
|
"email": {"type": "string", "description": "Email de contacto"},
|
||||||
|
"telefono": {"type": "string", "description": "Teléfono de contacto"},
|
||||||
|
},
|
||||||
|
"required": ["nombre"],
|
||||||
|
},
|
||||||
|
"servicios": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
|
||||||
|
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
|
||||||
|
},
|
||||||
|
"required": ["servicio_id"],
|
||||||
|
},
|
||||||
|
"description": "Lista de servicios a incluir en la cotización",
|
||||||
|
},
|
||||||
|
"plan_bucefalo": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["basico", "estandar", "premium", "empresarial"],
|
||||||
|
"description": "Nivel del plan CRM Bucefalo (opcional)",
|
||||||
|
},
|
||||||
|
"proyecto": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre o descripción del proyecto (default: 'MKT Digital')",
|
||||||
|
},
|
||||||
|
"moneda": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["MXN", "USD"],
|
||||||
|
"description": "Moneda de la cotización (default: MXN)",
|
||||||
|
},
|
||||||
|
"esquema_pago": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"],
|
||||||
|
"description": "Esquema de pago (default: Pago Unico/Mensual)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["cliente", "servicios"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "obtener_cotizacion",
|
||||||
|
"description": (
|
||||||
|
"Obtiene los detalles completos de una cotización existente incluyendo: "
|
||||||
|
"datos del cliente, servicios seleccionados con precios, estado actual, "
|
||||||
|
"plan CRM Bucefalo si aplica, observaciones, fechas y vigencia."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
||||||
|
},
|
||||||
|
"required": ["cotizacion_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listar_cotizaciones",
|
||||||
|
"description": (
|
||||||
|
"Lista cotizaciones con filtros opcionales. "
|
||||||
|
"Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. "
|
||||||
|
"Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"estado": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
|
||||||
|
"description": "Filtrar por estado",
|
||||||
|
},
|
||||||
|
"cliente_nombre": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Buscar por nombre de cliente",
|
||||||
|
},
|
||||||
|
"busqueda": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Texto libre para buscar en número, proyecto o cliente",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cambiar_estado_cotizacion",
|
||||||
|
"description": (
|
||||||
|
"Cambia el estado de una cotización. "
|
||||||
|
"Flujo normal: borrador → enviada → aprobada o rechazada. "
|
||||||
|
"Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. "
|
||||||
|
"Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
||||||
|
"estado": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
|
||||||
|
"description": "Nuevo estado de la cotización",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["cotizacion_id", "estado"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "actualizar_precio_servicio",
|
||||||
|
"description": (
|
||||||
|
"Actualiza el precio de un servicio específico dentro de una cotización. "
|
||||||
|
"No modifica el precio base del catálogo, solo el precio en esta cotización. "
|
||||||
|
"Útil para negociar precios individuales sin recrear toda la cotización."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
||||||
|
"servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"},
|
||||||
|
"nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"},
|
||||||
|
},
|
||||||
|
"required": ["cotizacion_id", "servicio_id", "nuevo_precio"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "duplicar_cotizacion",
|
||||||
|
"description": (
|
||||||
|
"Duplica una cotización existente como nueva copia en estado 'borrador'. "
|
||||||
|
"Crea una copia exacta con nuevo ID y número. "
|
||||||
|
"Útil para crear variaciones de una propuesta o reenviar una cotización actualizada."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"},
|
||||||
|
},
|
||||||
|
"required": ["cotizacion_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "calcular_financiamiento",
|
||||||
|
"description": (
|
||||||
|
"Calcula las mensualidades para financiar una cotización o monto específico. "
|
||||||
|
"Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). "
|
||||||
|
"Todos incluyen 2.5% de comisión + 16% IVA. "
|
||||||
|
"Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"monto": {"type": "number", "description": "Monto total a financiar en MXN"},
|
||||||
|
"meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"},
|
||||||
|
},
|
||||||
|
"required": ["monto", "meses"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "generar_pdf_cotizacion",
|
||||||
|
"description": (
|
||||||
|
"Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, "
|
||||||
|
"tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, "
|
||||||
|
"y datos bancarios para transferencia. Devuelve el archivo PDF."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"},
|
||||||
|
},
|
||||||
|
"required": ["cotizacion_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "obtener_configuracion",
|
||||||
|
"description": (
|
||||||
|
"Obtiene la configuración de la empresa Consultoría E3: "
|
||||||
|
"razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), "
|
||||||
|
"colores de marca, logo, y términos y condiciones. "
|
||||||
|
"Útil para generar documentos o verificar información fiscal."
|
||||||
|
),
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listar_bonos",
|
||||||
|
"description": (
|
||||||
|
"Lista los bonos/disponibles que se pueden incluir en una cotización: "
|
||||||
|
"1) Servicio Centinela Web (monitoreo 30 min/mes), "
|
||||||
|
"2) Workshop Buyer Persona, "
|
||||||
|
"3) Workshop Propuesta de Valor, "
|
||||||
|
"4) Membresía Premium (1 año), "
|
||||||
|
"5) Mes Gratis CRM Bucefalo, "
|
||||||
|
"6) Script de Ventas (100+ complementos)."
|
||||||
|
),
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listar_planes_bucefalo",
|
||||||
|
"description": (
|
||||||
|
"Lista los niveles del CRM Bucefalo con precios mensuales: "
|
||||||
|
"Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). "
|
||||||
|
"Bucefalo es un CRM para gestión de ventas y clientes."
|
||||||
|
),
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
RESOURCES = [
|
||||||
|
{
|
||||||
|
"uri": "cotizador://servicios",
|
||||||
|
"name": "Catálogo de Servicios",
|
||||||
|
"description": (
|
||||||
|
"Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: "
|
||||||
|
"Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), "
|
||||||
|
"Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, "
|
||||||
|
"tiempo de entrega, tipo de pago (único/mensual), y entregables."
|
||||||
|
),
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "cotizador://categorias",
|
||||||
|
"name": "Categorías de Servicios",
|
||||||
|
"description": (
|
||||||
|
"Categorías disponibles para clasificar servicios: "
|
||||||
|
"SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado."
|
||||||
|
),
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "cotizador://configuracion",
|
||||||
|
"name": "Configuración de la Empresa",
|
||||||
|
"description": (
|
||||||
|
"Datos fiscales, bancarios y de marca de Consultoría E3. "
|
||||||
|
"Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, "
|
||||||
|
"colores de marca, logo, y términos y condiciones."
|
||||||
|
),
|
||||||
|
"mimeType": "application/json",
|
||||||
|
},
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CategoriaBase(BaseModel):
|
||||||
|
nombre: str = Field(..., min_length=1, description="Nombre de la categoría")
|
||||||
|
descripcion: str | None = None
|
||||||
|
color: str = Field("#6b7280", description="Color hex para UI")
|
||||||
|
orden: int = Field(0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoriaCreate(CategoriaBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CategoriaUpdate(BaseModel):
|
||||||
|
nombre: str | None = Field(None, min_length=1)
|
||||||
|
descripcion: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
orden: int | None = Field(None, ge=0)
|
||||||
|
activo: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoriaResponse(CategoriaBase):
|
||||||
|
id: str
|
||||||
|
activo: bool
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ServicioCatalogoBase(BaseModel):
|
||||||
|
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
|
||||||
|
descripcion: str | None = None
|
||||||
|
fase: int = Field(..., ge=0, le=3, description="Fase: 0=Auditoría, 1=Setup, 2=Publicidad, 3=Contenido/SEO")
|
||||||
|
tipoPago: str = Field(..., pattern="^(unico|mensual)$", description="Tipo de pago")
|
||||||
|
precioBase: float = Field(..., ge=0, description="Precio base en la moneda local")
|
||||||
|
tiempoEntrega: str = Field("7 - 14 dias", description="Tiempo de entrega estimado")
|
||||||
|
entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables")
|
||||||
|
categoriaId: str = Field(..., min_length=1, description="ID de la categoría")
|
||||||
|
variante: str | None = None
|
||||||
|
orden: int = Field(0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ServicioCatalogoCreate(ServicioCatalogoBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServicioCatalogoUpdate(BaseModel):
|
||||||
|
nombre: str | None = Field(None, min_length=1)
|
||||||
|
descripcion: str | None = None
|
||||||
|
fase: int | None = Field(None, ge=0, le=3)
|
||||||
|
tipoPago: str | None = Field(None, pattern="^(unico|mensual)$")
|
||||||
|
precioBase: float | None = Field(None, ge=0)
|
||||||
|
tiempoEntrega: str | None = None
|
||||||
|
entregablesDefault: list[str] | None = None
|
||||||
|
categoriaId: str | None = None
|
||||||
|
variante: str | None = None
|
||||||
|
activo: bool | None = None
|
||||||
|
orden: int | None = Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ServicioCatalogoResponse(ServicioCatalogoBase):
|
||||||
|
id: str
|
||||||
|
activo: bool
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
categoriaRel: CategoriaResponse | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
from app.models.cliente import ClienteResponse
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteBase(BaseModel):
|
||||||
|
nombre: str = Field(..., min_length=1, description="Nombre completo del contacto")
|
||||||
|
empresa: str | None = Field(None, description="Nombre de la empresa")
|
||||||
|
email: str | None = Field(None, description="Email de contacto")
|
||||||
|
telefono: str | None = Field(None, description="Teléfono de contacto")
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteCreate(ClienteBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteUpdate(BaseModel):
|
||||||
|
nombre: str | None = Field(None, min_length=1)
|
||||||
|
empresa: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
telefono: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteResponse(ClienteBase):
|
||||||
|
id: str
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteWithCotizaciones(ClienteResponse):
|
||||||
|
cotizaciones: list[dict] | None = None
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class Meta(BaseModel):
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
limit: int = 50
|
||||||
|
pages: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
|
data: list[T]
|
||||||
|
meta: Meta
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
error: str
|
||||||
|
detail: str | None = None
|
||||||
|
code: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OkResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ArchivedResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
archived: bool
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: str = "ok"
|
||||||
|
timestamp: str
|
||||||
|
database: str = "connected"
|
||||||
|
|
||||||
|
|
||||||
|
class APIInfoResponse(BaseModel):
|
||||||
|
name: str = "Cotizador E3 API"
|
||||||
|
version: str = "1.0.0"
|
||||||
|
description: str = "API para sistema de cotizaciones de marketing digital"
|
||||||
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
user: dict[str, Any]
|
||||||
|
token: str | None = None
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
ALLOWED_CONFIG_KEYS: set[str] = {
|
||||||
|
"color_primario",
|
||||||
|
"color_secundario",
|
||||||
|
"logo_base64",
|
||||||
|
"razon_social",
|
||||||
|
"rfc",
|
||||||
|
"domicilio_fiscal",
|
||||||
|
"cuenta_nacional",
|
||||||
|
"clabe_interbancaria",
|
||||||
|
"cuenta_internacional",
|
||||||
|
"cuenta_internacional_swift",
|
||||||
|
"hora_centinela",
|
||||||
|
"anualidad_hosting",
|
||||||
|
"iva",
|
||||||
|
"terminos_condiciones",
|
||||||
|
"no_incluye",
|
||||||
|
"notas_adicionales",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigUpdate(BaseModel):
|
||||||
|
config: dict[str, str] = Field(
|
||||||
|
...,
|
||||||
|
description="Key-value config pairs. Only whitelisted keys are processed.",
|
||||||
|
json_schema_extra={
|
||||||
|
"examples": [
|
||||||
|
{"color_primario": "#ff0000", "razon_social": "Nueva Empresa SA de CV"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_allowed(self) -> dict[str, str]:
|
||||||
|
return {k: v for k, v in self.config.items() if k in ALLOWED_CONFIG_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigResponse(BaseModel):
|
||||||
|
config: dict[str, str]
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ServicioCotizadoInput(BaseModel):
|
||||||
|
catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo")
|
||||||
|
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
|
||||||
|
fase: int = Field(..., ge=0, le=3)
|
||||||
|
tipoPago: str = Field(..., pattern="^(unico|mensual)$")
|
||||||
|
precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)")
|
||||||
|
tiempoEntrega: str
|
||||||
|
entregables: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PlanBucefaloInput(BaseModel):
|
||||||
|
nivel: str = Field(..., description="Nivel: basico, estandar, premium, empresarial")
|
||||||
|
precio: float = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ClienteInput(BaseModel):
|
||||||
|
nombre: str = Field(..., min_length=1)
|
||||||
|
empresa: str = ""
|
||||||
|
email: str = ""
|
||||||
|
telefono: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CotizacionCreate(BaseModel):
|
||||||
|
numero: str = Field(..., min_length=1, description="Número de cotización (formato UJ{YY}{MM}{init}{seq})")
|
||||||
|
fecha: datetime
|
||||||
|
vigencia: datetime
|
||||||
|
moneda: str = Field("MXN", pattern="^(MXN|USD)$")
|
||||||
|
tipoCambio: str = "NA"
|
||||||
|
proyecto: str = "MKT Digital"
|
||||||
|
esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
|
||||||
|
incluirBonos: bool = False
|
||||||
|
incluirFinanciamiento: bool = False
|
||||||
|
observaciones: str = ""
|
||||||
|
asesorId: str = Field(..., min_length=1)
|
||||||
|
cliente: ClienteInput
|
||||||
|
servicios: list[ServicioCotizadoInput] = Field(..., min_length=1)
|
||||||
|
planBucefalo: PlanBucefaloInput | None = None
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"numero": "UJ2605AG001",
|
||||||
|
"fecha": "2026-05-03",
|
||||||
|
"vigencia": "2026-05-24",
|
||||||
|
"moneda": "MXN",
|
||||||
|
"tipoCambio": "NA",
|
||||||
|
"proyecto": "MKT Digital",
|
||||||
|
"esquemaPago": "Pago Unico/Mensual",
|
||||||
|
"incluirBonos": False,
|
||||||
|
"incluirFinanciamiento": False,
|
||||||
|
"observaciones": "",
|
||||||
|
"asesorId": "cuid-asesor",
|
||||||
|
"cliente": {
|
||||||
|
"nombre": "Juan Pérez",
|
||||||
|
"empresa": "ACME Corp",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"telefono": "4421234567",
|
||||||
|
},
|
||||||
|
"servicios": [
|
||||||
|
{
|
||||||
|
"catalogoId": "cuid-servicio",
|
||||||
|
"nombre": "SEO On-Page",
|
||||||
|
"fase": 3,
|
||||||
|
"tipoPago": "mensual",
|
||||||
|
"precio": 2900,
|
||||||
|
"tiempoEntrega": "7 - 14 dias",
|
||||||
|
"entregables": ["Keyword research"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"planBucefalo": {"nivel": "basico", "precio": 1000},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CotizacionUpdate(BaseModel):
|
||||||
|
fecha: datetime | None = None
|
||||||
|
vigencia: datetime | None = None
|
||||||
|
moneda: str | None = Field(None, pattern="^(MXN|USD)$")
|
||||||
|
tipoCambio: str | None = None
|
||||||
|
proyecto: str | None = None
|
||||||
|
esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
|
||||||
|
incluirBonos: bool | None = None
|
||||||
|
incluirFinanciamiento: bool | None = None
|
||||||
|
observaciones: str | None = None
|
||||||
|
estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$")
|
||||||
|
cliente: ClienteInput | None = None
|
||||||
|
servicios: list[ServicioCotizadoInput] | None = None
|
||||||
|
planBucefalo: PlanBucefaloInput | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CambiarEstadoRequest(BaseModel):
|
||||||
|
estado: str = Field(..., pattern="^(borrador|enviada|aprobada|rechazada)$")
|
||||||
|
|
||||||
|
|
||||||
|
class ActualizarPrecioRequest(BaseModel):
|
||||||
|
servicioId: str = Field(..., min_length=1)
|
||||||
|
precio: float = Field(..., ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class CotizacionResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
numero: str
|
||||||
|
fecha: datetime
|
||||||
|
vigencia: datetime
|
||||||
|
moneda: str
|
||||||
|
tipoCambio: str
|
||||||
|
proyecto: str
|
||||||
|
esquemaPago: str
|
||||||
|
estado: str
|
||||||
|
incluirBonos: bool
|
||||||
|
incluirFinanciamiento: bool
|
||||||
|
observaciones: str | None
|
||||||
|
clienteId: str
|
||||||
|
asesorId: str
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
cliente: dict[str, Any] | None = None
|
||||||
|
asesor: dict[str, Any] | None = None
|
||||||
|
servicios: list[dict[str, Any]] | None = None
|
||||||
|
planBucefalo: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ServicioDraft(BaseModel):
|
||||||
|
nombre: str
|
||||||
|
fase: int
|
||||||
|
tipoPago: str
|
||||||
|
precio: float
|
||||||
|
tiempoEntrega: str
|
||||||
|
entregables: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportDraft(BaseModel):
|
||||||
|
clienteNombre: str = ""
|
||||||
|
clienteEmpresa: str = ""
|
||||||
|
asesorNombre: str = ""
|
||||||
|
fecha: str = ""
|
||||||
|
moneda: str = "MXN"
|
||||||
|
tipoCambio: str = "NA"
|
||||||
|
proyecto: str = "MKT Digital"
|
||||||
|
esquemaPago: str = "Pago Unico/Mensual"
|
||||||
|
incluirBonos: bool = False
|
||||||
|
servicios: list[ServicioDraft] = Field(default_factory=list)
|
||||||
|
planBucefaloNivel: str | None = None
|
||||||
|
observaciones: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class FinanciamientoRequest(BaseModel):
|
||||||
|
monto: float = Field(..., ge=0, description="Monto total a financiar en MXN")
|
||||||
|
meses: int = Field(..., ge=3, le=12, description="Plazo en meses: 3, 6, 9, o 12")
|
||||||
|
|
||||||
|
|
||||||
|
class FinanciamientoResponse(BaseModel):
|
||||||
|
pagoMensual: float
|
||||||
|
ivaMensual: float
|
||||||
|
totalMensual: float
|
||||||
|
comisionTotal: float
|
||||||
|
granTotal: float
|
||||||
|
meses: int
|
||||||
|
tasa: float
|
||||||
|
comision: float
|
||||||
|
|
||||||
|
|
||||||
|
class BonoResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
numero: int
|
||||||
|
titulo: str
|
||||||
|
descripcion: str
|
||||||
|
activo: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PlanBucefaloResponse(BaseModel):
|
||||||
|
nivel: str
|
||||||
|
label: str
|
||||||
|
precio: float
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FasePaqueteInput(BaseModel):
|
||||||
|
nombre: str = Field(..., min_length=1)
|
||||||
|
orden: int = Field(0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PaqueteCreate(BaseModel):
|
||||||
|
nombre: str = Field(..., min_length=1, description="Nombre del paquete")
|
||||||
|
descripcion: str | None = None
|
||||||
|
fases: list[FasePaqueteInput] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PaqueteUpdate(BaseModel):
|
||||||
|
nombre: str | None = Field(None, min_length=1)
|
||||||
|
descripcion: str | None = None
|
||||||
|
activo: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ManageAction(BaseModel):
|
||||||
|
action: str = Field(..., description="Acción: addFase, updateFase, deleteFase, addServicio, removeServicio")
|
||||||
|
faseId: str | None = None
|
||||||
|
nombre: str | None = None
|
||||||
|
orden: int | None = None
|
||||||
|
servicioCatalogoId: str | None = None
|
||||||
|
fasePaqueteId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FasePaqueteResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
paqueteId: str
|
||||||
|
nombre: str
|
||||||
|
orden: int
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
servicios: list[dict] | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PaqueteResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
nombre: str
|
||||||
|
descripcion: str | None
|
||||||
|
activo: bool
|
||||||
|
createdAt: datetime
|
||||||
|
updatedAt: datetime
|
||||||
|
fases: list[FasePaqueteResponse] | None = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.auth import verify_password, create_access_token, require_auth, get_password_hash
|
||||||
|
from app.models.common import LoginRequest, LoginResponse, OkResponse, ErrorResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
async def login(body: LoginRequest):
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id, email, password, name, role FROM "User" WHERE email = $1',
|
||||||
|
body.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Credenciales inválidas",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verify_password(body.password, row["password"]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Credenciales inválidas",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = create_access_token({"sub": str(row["id"]), "email": row["email"], "role": row["role"]})
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=token,
|
||||||
|
user={
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"name": row["name"],
|
||||||
|
"role": row["role"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=OkResponse)
|
||||||
|
async def logout():
|
||||||
|
return OkResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(current_user: dict = Depends(require_auth)):
|
||||||
|
pool = get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id, email, name, role, "createdAt", "updatedAt" FROM "User" WHERE id = $1',
|
||||||
|
current_user["sub"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Usuario no encontrado",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"name": row["name"],
|
||||||
|
"role": row["role"],
|
||||||
|
"createdAt": row["createdAt"].isoformat() if row["createdAt"] else None,
|
||||||
|
"updatedAt": row["updatedAt"].isoformat() if row["updatedAt"] else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.models.export_ import BonoResponse
|
||||||
|
from app.services.calculators import BONOS
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bonos", tags=["Bonos"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BonoResponse])
|
||||||
|
async def get_bonos(_auth: dict = Depends(require_auth)):
|
||||||
|
return [BonoResponse(**b) for b in BONOS]
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.models.catalogo import (
|
||||||
|
ServicioCatalogoCreate,
|
||||||
|
ServicioCatalogoUpdate,
|
||||||
|
ServicioCatalogoResponse,
|
||||||
|
CategoriaResponse,
|
||||||
|
)
|
||||||
|
from app.models.common import ArchivedResponse, ErrorResponse, OkResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/catalogo", tags=["Catalogo"])
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse:
|
||||||
|
entregables = row["entregablesDefault"]
|
||||||
|
if isinstance(entregables, str):
|
||||||
|
entregables = json.loads(entregables)
|
||||||
|
|
||||||
|
categoria = None
|
||||||
|
if categoria_row:
|
||||||
|
categoria = CategoriaResponse(
|
||||||
|
id=categoria_row["id"],
|
||||||
|
nombre=categoria_row["nombre"],
|
||||||
|
descripcion=categoria_row["descripcion"],
|
||||||
|
color=categoria_row["color"],
|
||||||
|
orden=categoria_row["orden"],
|
||||||
|
activo=categoria_row["activo"],
|
||||||
|
createdAt=categoria_row["createdAt"],
|
||||||
|
updatedAt=categoria_row["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return ServicioCatalogoResponse(
|
||||||
|
id=row["id"],
|
||||||
|
nombre=row["nombre"],
|
||||||
|
descripcion=row["descripcion"],
|
||||||
|
fase=row["fase"],
|
||||||
|
tipoPago=row["tipoPago"],
|
||||||
|
precioBase=float(row["precioBase"]),
|
||||||
|
tiempoEntrega=row["tiempoEntrega"],
|
||||||
|
entregablesDefault=entregables,
|
||||||
|
categoriaId=row["categoriaId"],
|
||||||
|
variante=row["variante"],
|
||||||
|
activo=row["activo"],
|
||||||
|
orden=row["orden"],
|
||||||
|
createdAt=row["createdAt"],
|
||||||
|
updatedAt=row["updatedAt"],
|
||||||
|
categoriaRel=categoria,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=list[ServicioCatalogoResponse],
|
||||||
|
summary="List active catalog services with filters",
|
||||||
|
)
|
||||||
|
async def list_catalogo(
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
fase: int | None = Query(None, ge=0, le=3),
|
||||||
|
tipoPago: str | None = Query(None, pattern="^(unico|mensual)$"),
|
||||||
|
categoriaId: str | None = Query(None),
|
||||||
|
q: str | None = Query(None, description="Search by nombre or descripcion"),
|
||||||
|
):
|
||||||
|
where_clauses: list[str] = ['activo = true']
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if fase is not None:
|
||||||
|
where_clauses.append(f"fase = ${idx}")
|
||||||
|
params.append(fase)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if tipoPago:
|
||||||
|
where_clauses.append(f'"tipoPago" = ${idx}')
|
||||||
|
params.append(tipoPago)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if categoriaId:
|
||||||
|
where_clauses.append(f'"categoriaId" = ${idx}')
|
||||||
|
params.append(categoriaId)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if q:
|
||||||
|
where_clauses.append(f"(nombre ILIKE ${idx} OR descripcion ILIKE ${idx})")
|
||||||
|
params.append(f"%{q}%")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where_sql = f"WHERE {' AND '.join(where_clauses)}"
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
f'SELECT * FROM "ServicioCatalogo" {where_sql} ORDER BY fase ASC, orden ASC',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
result: list[ServicioCatalogoResponse] = []
|
||||||
|
for r in rows:
|
||||||
|
cat_row = None
|
||||||
|
if r["categoriaId"]:
|
||||||
|
cat_row = await db.fetchrow(
|
||||||
|
'SELECT * FROM "Categoria" WHERE id = $1', r["categoriaId"]
|
||||||
|
)
|
||||||
|
result.append(_row_to_response(r, cat_row))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ServicioCatalogoResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a new catalog service",
|
||||||
|
)
|
||||||
|
async def create_catalogo(
|
||||||
|
body: ServicioCatalogoCreate,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
if body.categoriaId:
|
||||||
|
cat = await db.fetchrow(
|
||||||
|
'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId
|
||||||
|
)
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=400, detail="Categoria no encontrada")
|
||||||
|
|
||||||
|
entregables_json = json.dumps(body.entregablesDefault)
|
||||||
|
|
||||||
|
row = await db.fetchrow(
|
||||||
|
'INSERT INTO "ServicioCatalogo" '
|
||||||
|
'(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", '
|
||||||
|
'"entregablesDefault", "categoriaId", variante, activo, orden) '
|
||||||
|
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10) RETURNING *",
|
||||||
|
body.nombre,
|
||||||
|
body.descripcion,
|
||||||
|
body.fase,
|
||||||
|
body.tipoPago,
|
||||||
|
body.precioBase,
|
||||||
|
body.tiempoEntrega,
|
||||||
|
entregables_json,
|
||||||
|
body.categoriaId,
|
||||||
|
body.variante,
|
||||||
|
body.orden,
|
||||||
|
)
|
||||||
|
|
||||||
|
cat_row = None
|
||||||
|
if row["categoriaId"]:
|
||||||
|
cat_row = await db.fetchrow(
|
||||||
|
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return _row_to_response(row, cat_row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{servicio_id}",
|
||||||
|
response_model=ServicioCatalogoResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
summary="Get catalog service by ID with categoria join",
|
||||||
|
)
|
||||||
|
async def get_catalogo(
|
||||||
|
servicio_id: str,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
'SELECT * FROM "ServicioCatalogo" WHERE id = $1', servicio_id
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
|
||||||
|
cat_row = None
|
||||||
|
if row["categoriaId"]:
|
||||||
|
cat_row = await db.fetchrow(
|
||||||
|
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return _row_to_response(row, cat_row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{servicio_id}",
|
||||||
|
response_model=ServicioCatalogoResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
summary="Update a catalog service",
|
||||||
|
)
|
||||||
|
async def update_catalogo(
|
||||||
|
servicio_id: str,
|
||||||
|
body: ServicioCatalogoUpdate,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
existing = await db.fetchrow(
|
||||||
|
'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
|
||||||
|
if body.categoriaId:
|
||||||
|
cat = await db.fetchrow(
|
||||||
|
'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId
|
||||||
|
)
|
||||||
|
if not cat:
|
||||||
|
raise HTTPException(status_code=400, detail="Categoria no encontrada")
|
||||||
|
|
||||||
|
field_map = {
|
||||||
|
"nombre": "nombre",
|
||||||
|
"descripcion": "descripcion",
|
||||||
|
"fase": "fase",
|
||||||
|
"tipoPago": '"tipoPago"',
|
||||||
|
"precioBase": '"precioBase"',
|
||||||
|
"tiempoEntrega": '"tiempoEntrega"',
|
||||||
|
"entregablesDefault": '"entregablesDefault"',
|
||||||
|
"categoriaId": '"categoriaId"',
|
||||||
|
"variante": "variante",
|
||||||
|
"activo": "activo",
|
||||||
|
"orden": "orden",
|
||||||
|
}
|
||||||
|
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
for field, col in field_map.items():
|
||||||
|
value = getattr(body, field, None)
|
||||||
|
if value is not None:
|
||||||
|
if field == "entregablesDefault":
|
||||||
|
value = json.dumps(value)
|
||||||
|
updates.append(f"{col} = ${idx}")
|
||||||
|
params.append(value)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
params.append(servicio_id)
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f'UPDATE "ServicioCatalogo" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
cat_row = None
|
||||||
|
if row["categoriaId"]:
|
||||||
|
cat_row = await db.fetchrow(
|
||||||
|
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return _row_to_response(row, cat_row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{servicio_id}",
|
||||||
|
response_model=ArchivedResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
summary="Delete or soft-delete a catalog service",
|
||||||
|
)
|
||||||
|
async def delete_catalogo(
|
||||||
|
servicio_id: str,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
existing = await db.fetchrow(
|
||||||
|
'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no encontrado")
|
||||||
|
|
||||||
|
ref_count = await db.fetchval(
|
||||||
|
'SELECT COUNT(*) FROM "ServicioCotizado" WHERE "servicioCatalogoId" = $1',
|
||||||
|
servicio_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ref_count > 0:
|
||||||
|
await db.execute(
|
||||||
|
'UPDATE "ServicioCatalogo" SET activo = false WHERE id = $1', servicio_id
|
||||||
|
)
|
||||||
|
return ArchivedResponse(ok=True, archived=True)
|
||||||
|
|
||||||
|
await db.execute('DELETE FROM "ServicioCatalogo" WHERE id = $1', servicio_id)
|
||||||
|
return ArchivedResponse(ok=True, archived=False)
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.models.catalogo import CategoriaCreate, CategoriaUpdate, CategoriaResponse
|
||||||
|
from app.models.common import ErrorResponse, OkResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/categorias", tags=["Categorias"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CategoriaResponse])
|
||||||
|
async def list_categorias(
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
rows = await conn.fetch(
|
||||||
|
'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"'
|
||||||
|
' FROM "Categoria" ORDER BY orden ASC'
|
||||||
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=CategoriaResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
responses={409: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def create_categoria(
|
||||||
|
body: CategoriaCreate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'INSERT INTO "Categoria" (id, nombre, descripcion, color, orden, "createdAt", "updatedAt")'
|
||||||
|
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $5)"
|
||||||
|
' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"',
|
||||||
|
body.nombre,
|
||||||
|
body.descripcion,
|
||||||
|
body.color,
|
||||||
|
body.orden,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Ya existe una categoría con el nombre '{body.nombre}'",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{categoria_id}",
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def get_categoria(
|
||||||
|
categoria_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT c.id, c.nombre, c.descripcion, c.color, c.activo, c.orden,'
|
||||||
|
' c."createdAt", c."updatedAt",'
|
||||||
|
' COUNT(s.id)::int AS "serviciosCount"'
|
||||||
|
' FROM "Categoria" c'
|
||||||
|
' LEFT JOIN "ServicioCatalogo" s ON s."categoriaId" = c.id'
|
||||||
|
" WHERE c.id = $1"
|
||||||
|
" GROUP BY c.id",
|
||||||
|
categoria_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Categoría no encontrada",
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{categoria_id}",
|
||||||
|
response_model=CategoriaResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def update_categoria(
|
||||||
|
categoria_id: str,
|
||||||
|
body: CategoriaUpdate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "Categoria" WHERE id = $1', categoria_id
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Categoría no encontrada",
|
||||||
|
)
|
||||||
|
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if body.nombre is not None:
|
||||||
|
updates.append(f"nombre = ${idx}")
|
||||||
|
params.append(body.nombre)
|
||||||
|
idx += 1
|
||||||
|
if body.descripcion is not None:
|
||||||
|
updates.append(f"descripcion = ${idx}")
|
||||||
|
params.append(body.descripcion)
|
||||||
|
idx += 1
|
||||||
|
if body.color is not None:
|
||||||
|
updates.append(f"color = ${idx}")
|
||||||
|
params.append(body.color)
|
||||||
|
idx += 1
|
||||||
|
if body.orden is not None:
|
||||||
|
updates.append(f"orden = ${idx}")
|
||||||
|
params.append(body.orden)
|
||||||
|
idx += 1
|
||||||
|
if body.activo is not None:
|
||||||
|
updates.append(f"activo = ${idx}")
|
||||||
|
params.append(body.activo)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"'
|
||||||
|
' FROM "Categoria" WHERE id = $1',
|
||||||
|
categoria_id,
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
updates.append(f'"updatedAt" = ${idx}')
|
||||||
|
params.append(datetime.now(timezone.utc))
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
params.append(categoria_id)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f'UPDATE "Categoria" SET {", ".join(updates)} WHERE id = ${idx}'
|
||||||
|
' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Ya existe una categoría con el nombre '{body.nombre}'",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{categoria_id}",
|
||||||
|
response_model=OkResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def delete_categoria(
|
||||||
|
categoria_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "Categoria" WHERE id = $1', categoria_id
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Categoría no encontrada",
|
||||||
|
)
|
||||||
|
|
||||||
|
count = await conn.fetchval(
|
||||||
|
'SELECT COUNT(*)::int FROM "ServicioCatalogo" WHERE "categoriaId" = $1',
|
||||||
|
categoria_id,
|
||||||
|
)
|
||||||
|
if count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Tiene {count} servicios asociados",
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute('DELETE FROM "Categoria" WHERE id = $1', categoria_id)
|
||||||
|
return OkResponse(ok=True)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.dependencies import PaginationParams, get_db
|
||||||
|
from app.models.cliente import ClienteCreate, ClienteUpdate, ClienteResponse
|
||||||
|
from app.models.common import PaginatedResponse, Meta, ErrorResponse, OkResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/clientes", tags=["Clientes"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=PaginatedResponse[ClienteResponse],
|
||||||
|
summary="List clients with pagination and search",
|
||||||
|
)
|
||||||
|
async def list_clientes(
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
pagination: PaginationParams = Depends(PaginationParams),
|
||||||
|
q: str | None = Query(None, description="Search by nombre, empresa, or email"),
|
||||||
|
):
|
||||||
|
where_clauses: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if q:
|
||||||
|
where_clauses.append(
|
||||||
|
f"(nombre ILIKE ${idx} OR empresa ILIKE ${idx} OR email ILIKE ${idx})"
|
||||||
|
)
|
||||||
|
params.append(f"%{q}%")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||||
|
|
||||||
|
count_row = await db.fetchrow(
|
||||||
|
f"SELECT COUNT(*) AS total FROM \"Cliente\" {where_sql}", *params
|
||||||
|
)
|
||||||
|
total = count_row["total"]
|
||||||
|
pages = math.ceil(total / pagination.limit) if total > 0 else 0
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
f'SELECT * FROM "Cliente" {where_sql} ORDER BY "createdAt" DESC '
|
||||||
|
f"LIMIT ${idx} OFFSET ${idx + 1}",
|
||||||
|
*params,
|
||||||
|
pagination.limit,
|
||||||
|
pagination.offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [
|
||||||
|
ClienteResponse(
|
||||||
|
id=r["id"],
|
||||||
|
nombre=r["nombre"],
|
||||||
|
empresa=r["empresa"],
|
||||||
|
email=r["email"],
|
||||||
|
telefono=r["telefono"],
|
||||||
|
createdAt=r["createdAt"],
|
||||||
|
updatedAt=r["updatedAt"],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=data,
|
||||||
|
meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=ClienteResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a new client",
|
||||||
|
)
|
||||||
|
async def create_cliente(
|
||||||
|
body: ClienteCreate,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
row = await db.fetchrow(
|
||||||
|
'INSERT INTO "Cliente" (nombre, empresa, email, telefono) '
|
||||||
|
"VALUES ($1, $2, $3, $4) RETURNING *",
|
||||||
|
body.nombre,
|
||||||
|
body.empresa,
|
||||||
|
body.email,
|
||||||
|
body.telefono,
|
||||||
|
)
|
||||||
|
return ClienteResponse(
|
||||||
|
id=row["id"],
|
||||||
|
nombre=row["nombre"],
|
||||||
|
empresa=row["empresa"],
|
||||||
|
email=row["email"],
|
||||||
|
telefono=row["telefono"],
|
||||||
|
createdAt=row["createdAt"],
|
||||||
|
updatedAt=row["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{cliente_id}",
|
||||||
|
response_model=ClienteResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
summary="Get client by ID with recent cotizaciones",
|
||||||
|
)
|
||||||
|
async def get_cliente(
|
||||||
|
cliente_id: str,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
row = await db.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cliente_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
|
||||||
|
cotizaciones = await db.fetch(
|
||||||
|
'SELECT * FROM "Cotizacion" WHERE "clienteId" = $1 '
|
||||||
|
'ORDER BY "createdAt" DESC LIMIT 10',
|
||||||
|
cliente_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliente = ClienteResponse(
|
||||||
|
id=row["id"],
|
||||||
|
nombre=row["nombre"],
|
||||||
|
empresa=row["empresa"],
|
||||||
|
email=row["email"],
|
||||||
|
telefono=row["telefono"],
|
||||||
|
createdAt=row["createdAt"],
|
||||||
|
updatedAt=row["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return cliente.model_copy(
|
||||||
|
update={"cotizaciones": [dict(c) for c in cotizaciones]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{cliente_id}",
|
||||||
|
response_model=ClienteResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
summary="Update a client",
|
||||||
|
)
|
||||||
|
async def update_cliente(
|
||||||
|
cliente_id: str,
|
||||||
|
body: ClienteUpdate,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
for field in ("nombre", "empresa", "email", "telefono"):
|
||||||
|
value = getattr(body, field, None)
|
||||||
|
if value is not None:
|
||||||
|
updates.append(f"{field} = ${idx}")
|
||||||
|
params.append(value)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="No fields to update")
|
||||||
|
|
||||||
|
params.append(cliente_id)
|
||||||
|
row = await db.fetchrow(
|
||||||
|
f'UPDATE "Cliente" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ClienteResponse(
|
||||||
|
id=row["id"],
|
||||||
|
nombre=row["nombre"],
|
||||||
|
empresa=row["empresa"],
|
||||||
|
email=row["email"],
|
||||||
|
telefono=row["telefono"],
|
||||||
|
createdAt=row["createdAt"],
|
||||||
|
updatedAt=row["updatedAt"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{cliente_id}",
|
||||||
|
response_model=OkResponse,
|
||||||
|
responses={409: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||||
|
summary="Delete a client (fails if cotizaciones exist)",
|
||||||
|
)
|
||||||
|
async def delete_cliente(
|
||||||
|
cliente_id: str,
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Cliente no encontrado")
|
||||||
|
|
||||||
|
cot_count = await db.fetchval(
|
||||||
|
'SELECT COUNT(*) FROM "Cotizacion" WHERE "clienteId" = $1', cliente_id
|
||||||
|
)
|
||||||
|
if cot_count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="No se puede eliminar: el cliente tiene cotizaciones asociadas",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute('DELETE FROM "Cliente" WHERE id = $1', cliente_id)
|
||||||
|
return OkResponse()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.models.configuracion import ConfigUpdate, ConfigResponse, ALLOWED_CONFIG_KEYS
|
||||||
|
from app.models.common import ErrorResponse, OkResponse
|
||||||
|
from app.auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/configuracion", tags=["Configuración"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ConfigResponse)
|
||||||
|
async def get_configuracion(
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
rows = await db.fetch("SELECT clave, valor FROM Configuracion")
|
||||||
|
config = {row["clave"]: row["valor"] for row in rows}
|
||||||
|
return ConfigResponse(config=config)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=OkResponse, responses={400: {"model": ErrorResponse}})
|
||||||
|
async def update_configuracion(
|
||||||
|
body: ConfigUpdate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
allowed = body.get_allowed()
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No valid config keys provided",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with db.transaction():
|
||||||
|
for clave, valor in allowed.items():
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "Configuracion" (id, clave, valor, "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, now())
|
||||||
|
ON CONFLICT (clave) DO UPDATE SET valor = $2, "updatedAt" = now()
|
||||||
|
""",
|
||||||
|
clave,
|
||||||
|
valor,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OkResponse()
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.dependencies import PaginationParams, parse_date
|
||||||
|
from app.models.common import Meta, OkResponse, PaginatedResponse
|
||||||
|
from app.models.cotizacion import (
|
||||||
|
ActualizarPrecioRequest,
|
||||||
|
CambiarEstadoRequest,
|
||||||
|
CotizacionCreate,
|
||||||
|
CotizacionResponse,
|
||||||
|
CotizacionUpdate,
|
||||||
|
)
|
||||||
|
from app.services.calculators import ESTADOS_COTIZACION
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/cotizaciones", tags=["Cotizaciones"])
|
||||||
|
|
||||||
|
|
||||||
|
def _cuid() -> str:
|
||||||
|
return uuid.uuid4().hex[:25]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
|
||||||
|
d = dict(cot)
|
||||||
|
d["cliente"] = dict(cliente) if cliente else None
|
||||||
|
d["asesor"] = dict(asesor) if asesor else None
|
||||||
|
d["servicios"] = [dict(s) for s in servicios] if servicios else []
|
||||||
|
d["planBucefalo"] = dict(plan) if plan else None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
|
||||||
|
cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
|
||||||
|
if not cot:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cliente = await conn.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cot["clienteId"])
|
||||||
|
asesor = await conn.fetchrow('SELECT id, email, name, role FROM "User" WHERE id = $1', cot["asesorId"])
|
||||||
|
servicios = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
|
||||||
|
s.nombre, s.descripcion, s."categoriaId"
|
||||||
|
FROM "ServicioCotizado" sc
|
||||||
|
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
|
||||||
|
WHERE sc."cotizacionId" = $1
|
||||||
|
ORDER BY sc.fase, sc.id
|
||||||
|
""",
|
||||||
|
cot_id,
|
||||||
|
)
|
||||||
|
plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
|
||||||
|
|
||||||
|
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_bucefalo_servicio(conn) -> str | None:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT s.id FROM "ServicioCatalogo" s
|
||||||
|
JOIN "Categoria" c ON c.id = s."categoriaId"
|
||||||
|
WHERE c.nombre = 'CRM' AND s."tipoPago" = 'mensual' AND s.activo = true
|
||||||
|
ORDER BY s.orden LIMIT 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_or_create_cliente(conn, nombre: str, empresa: str, email: str, telefono: str) -> str:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "Cliente" WHERE nombre = $1 AND COALESCE(empresa, \'\') = $2',
|
||||||
|
nombre,
|
||||||
|
empresa or "",
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
if email or telefono:
|
||||||
|
await conn.execute(
|
||||||
|
'UPDATE "Cliente" SET email = COALESCE($1, email), telefono = COALESCE($2, telefono), "updatedAt" = NOW() WHERE id = $3',
|
||||||
|
email or None,
|
||||||
|
telefono or None,
|
||||||
|
row["id"],
|
||||||
|
)
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
new_id = _cuid()
|
||||||
|
await conn.execute(
|
||||||
|
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, $5, NOW(), NOW())',
|
||||||
|
new_id,
|
||||||
|
nombre,
|
||||||
|
empresa or None,
|
||||||
|
email or None,
|
||||||
|
telefono or None,
|
||||||
|
)
|
||||||
|
return new_id
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. GET /cotizaciones — List with filters + pagination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("", response_model=PaginatedResponse[CotizacionResponse])
|
||||||
|
async def list_cotizaciones(
|
||||||
|
pagination: PaginationParams = Depends(),
|
||||||
|
estado: str | None = Query(None),
|
||||||
|
asesorId: str | None = Query(None),
|
||||||
|
clienteId: str | None = Query(None),
|
||||||
|
q: str | None = Query(None),
|
||||||
|
desde: str | None = Query(None),
|
||||||
|
hasta: str | None = Query(None),
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pool = await get_pool()
|
||||||
|
|
||||||
|
conditions: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if estado:
|
||||||
|
conditions.append(f'c.estado = ${idx}')
|
||||||
|
params.append(estado)
|
||||||
|
idx += 1
|
||||||
|
if asesorId:
|
||||||
|
conditions.append(f'c."asesorId" = ${idx}')
|
||||||
|
params.append(asesorId)
|
||||||
|
idx += 1
|
||||||
|
if clienteId:
|
||||||
|
conditions.append(f'c."clienteId" = ${idx}')
|
||||||
|
params.append(clienteId)
|
||||||
|
idx += 1
|
||||||
|
if q:
|
||||||
|
conditions.append(
|
||||||
|
f'(c.numero ILIKE ${idx} OR cl.nombre ILIKE ${idx} OR c.proyecto ILIKE ${idx})'
|
||||||
|
)
|
||||||
|
params.append(f"%{q}%")
|
||||||
|
idx += 1
|
||||||
|
desde_dt = parse_date(desde)
|
||||||
|
if desde_dt:
|
||||||
|
conditions.append(f'c.fecha >= ${idx}')
|
||||||
|
params.append(desde_dt)
|
||||||
|
idx += 1
|
||||||
|
hasta_dt = parse_date(hasta)
|
||||||
|
if hasta_dt:
|
||||||
|
conditions.append(f'c.fecha <= ${idx}')
|
||||||
|
params.append(hasta_dt)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||||
|
|
||||||
|
count_row = await pool.fetchrow(
|
||||||
|
f'SELECT COUNT(*) FROM "Cotizacion" c LEFT JOIN "Cliente" cl ON cl.id = c."clienteId" {where}',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
total = count_row["count"]
|
||||||
|
|
||||||
|
rows = await pool.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT c.* FROM "Cotizacion" c
|
||||||
|
LEFT JOIN "Cliente" cl ON cl.id = c."clienteId"
|
||||||
|
{where}
|
||||||
|
ORDER BY c."createdAt" DESC
|
||||||
|
LIMIT ${idx} OFFSET ${idx + 1}
|
||||||
|
""",
|
||||||
|
*params,
|
||||||
|
pagination.limit,
|
||||||
|
pagination.offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
cotizaciones = []
|
||||||
|
for r in rows:
|
||||||
|
full = await _fetch_cotizacion_full(pool, r["id"])
|
||||||
|
if full:
|
||||||
|
cotizaciones.append(full)
|
||||||
|
|
||||||
|
pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
data=cotizaciones,
|
||||||
|
meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. POST /cotizaciones — Create
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(require_auth)):
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
cliente_id = await _find_or_create_cliente(
|
||||||
|
conn,
|
||||||
|
body.cliente.nombre,
|
||||||
|
body.cliente.empresa,
|
||||||
|
body.cliente.email,
|
||||||
|
body.cliente.telefono,
|
||||||
|
)
|
||||||
|
|
||||||
|
cot_id = _cuid()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "Cotizacion"
|
||||||
|
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
||||||
|
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
|
||||||
|
"clienteId", "asesorId", "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
|
||||||
|
""",
|
||||||
|
cot_id,
|
||||||
|
body.numero,
|
||||||
|
body.fecha,
|
||||||
|
body.vigencia,
|
||||||
|
body.moneda,
|
||||||
|
body.tipoCambio,
|
||||||
|
body.proyecto,
|
||||||
|
body.esquemaPago,
|
||||||
|
body.incluirBonos,
|
||||||
|
body.incluirFinanciamiento,
|
||||||
|
body.observaciones or None,
|
||||||
|
cliente_id,
|
||||||
|
body.asesorId,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
|
||||||
|
|
||||||
|
for svc in body.servicios:
|
||||||
|
catalogo_id = svc.catalogoId
|
||||||
|
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
|
||||||
|
catalogo_id = bucefalo_servicio_id
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "ServicioCotizado"
|
||||||
|
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
|
||||||
|
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
|
||||||
|
""",
|
||||||
|
_cuid(),
|
||||||
|
cot_id,
|
||||||
|
catalogo_id,
|
||||||
|
svc.fase,
|
||||||
|
svc.tipoPago,
|
||||||
|
svc.precio,
|
||||||
|
svc.tiempoEntrega,
|
||||||
|
json.dumps(svc.entregables),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.planBucefalo:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "PlanBucefaloCotizacion"
|
||||||
|
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,true,$5,$5)
|
||||||
|
""",
|
||||||
|
_cuid(),
|
||||||
|
cot_id,
|
||||||
|
body.planBucefalo.nivel,
|
||||||
|
body.planBucefalo.precio,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. GET /cotizaciones/{id} — Get with relations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.get("/{cotizacion_id}", response_model=CotizacionResponse)
|
||||||
|
async def get_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
|
||||||
|
pool = await get_pool()
|
||||||
|
full = await _fetch_cotizacion_full(pool, cotizacion_id)
|
||||||
|
if not full:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. PUT /cotizaciones/{id} — Update
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.put("/{cotizacion_id}", response_model=CotizacionResponse)
|
||||||
|
async def update_cotizacion(
|
||||||
|
cotizacion_id: str,
|
||||||
|
body: CotizacionUpdate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
existing = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
cliente_id = existing["clienteId"]
|
||||||
|
if body.cliente:
|
||||||
|
cliente_id = await _find_or_create_cliente(
|
||||||
|
conn,
|
||||||
|
body.cliente.nombre,
|
||||||
|
body.cliente.empresa,
|
||||||
|
body.cliente.email,
|
||||||
|
body.cliente.telefono,
|
||||||
|
)
|
||||||
|
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
def _add(field: str, col: str, val):
|
||||||
|
nonlocal idx
|
||||||
|
if val is not None:
|
||||||
|
updates.append(f'{col} = ${idx}')
|
||||||
|
params.append(val)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
_add("fecha", "fecha", body.fecha)
|
||||||
|
_add("vigencia", "vigencia", body.vigencia)
|
||||||
|
_add("moneda", "moneda", body.moneda)
|
||||||
|
_add("tipoCambio", '"tipoCambio"', body.tipoCambio)
|
||||||
|
_add("proyecto", "proyecto", body.proyecto)
|
||||||
|
_add("esquemaPago", '"esquemaPago"', body.esquemaPago)
|
||||||
|
_add("incluirBonos", '"incluirBonos"', body.incluirBonos)
|
||||||
|
_add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento)
|
||||||
|
_add("observaciones", "observaciones", body.observaciones)
|
||||||
|
_add("estado", "estado", body.estado)
|
||||||
|
|
||||||
|
if cliente_id != existing["clienteId"]:
|
||||||
|
updates.append(f'"clienteId" = ${idx}')
|
||||||
|
params.append(cliente_id)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
updates.append(f'"updatedAt" = ${idx}')
|
||||||
|
params.append(datetime.now(timezone.utc))
|
||||||
|
idx += 1
|
||||||
|
params.append(cotizacion_id)
|
||||||
|
await conn.execute(
|
||||||
|
f'UPDATE "Cotizacion" SET {", ".join(updates)} WHERE id = ${idx}',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.servicios is not None:
|
||||||
|
await conn.execute('DELETE FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cotizacion_id)
|
||||||
|
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for svc in body.servicios:
|
||||||
|
catalogo_id = svc.catalogoId
|
||||||
|
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
|
||||||
|
catalogo_id = bucefalo_servicio_id
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "ServicioCotizado"
|
||||||
|
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
|
||||||
|
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
|
||||||
|
""",
|
||||||
|
_cuid(),
|
||||||
|
cotizacion_id,
|
||||||
|
catalogo_id,
|
||||||
|
svc.fase,
|
||||||
|
svc.tipoPago,
|
||||||
|
svc.precio,
|
||||||
|
svc.tiempoEntrega,
|
||||||
|
json.dumps(svc.entregables),
|
||||||
|
None,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
if body.planBucefalo is not None:
|
||||||
|
existing_plan = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if existing_plan:
|
||||||
|
await conn.execute(
|
||||||
|
'UPDATE "PlanBucefaloCotizacion" SET nivel = $1, precio = $2, "updatedAt" = $3 WHERE id = $4',
|
||||||
|
body.planBucefalo.nivel,
|
||||||
|
body.planBucefalo.precio,
|
||||||
|
now,
|
||||||
|
existing_plan["id"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "PlanBucefaloCotizacion"
|
||||||
|
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,true,$5,$5)
|
||||||
|
""",
|
||||||
|
_cuid(),
|
||||||
|
cotizacion_id,
|
||||||
|
body.planBucefalo.nivel,
|
||||||
|
body.planBucefalo.precio,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
elif body.planBucefalo is None and "planBucefalo" in body.model_fields_set:
|
||||||
|
await conn.execute(
|
||||||
|
'DELETE FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
full = await _fetch_cotizacion_full(pool, cotizacion_id)
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. DELETE /cotizaciones/{id}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.delete("/{cotizacion_id}", response_model=OkResponse)
|
||||||
|
async def delete_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
|
||||||
|
pool = await get_pool()
|
||||||
|
existing = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
await pool.execute('DELETE FROM "Cotizacion" WHERE id = $1', cotizacion_id)
|
||||||
|
return OkResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. PATCH /cotizaciones/{id}/precio
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.patch("/{cotizacion_id}/precio", response_model=OkResponse)
|
||||||
|
async def update_precio_servicio(
|
||||||
|
cotizacion_id: str,
|
||||||
|
body: ActualizarPrecioRequest,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pool = await get_pool()
|
||||||
|
cot = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id)
|
||||||
|
if not cot:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
|
||||||
|
svc = await pool.fetchrow(
|
||||||
|
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
|
||||||
|
body.servicioId,
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
if not svc:
|
||||||
|
raise HTTPException(status_code=404, detail="Servicio no pertenece a esta cotización")
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
|
||||||
|
body.precio,
|
||||||
|
body.servicioId,
|
||||||
|
)
|
||||||
|
return OkResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. PATCH /cotizaciones/{id}/estado
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.patch("/{cotizacion_id}/estado", response_model=OkResponse)
|
||||||
|
async def cambiar_estado(
|
||||||
|
cotizacion_id: str,
|
||||||
|
body: CambiarEstadoRequest,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
if body.estado not in ESTADOS_COTIZACION:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Estado inválido. Permitidos: {', '.join(ESTADOS_COTIZACION)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
result = await pool.execute(
|
||||||
|
'UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2',
|
||||||
|
body.estado,
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
if result == "UPDATE 0":
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
return OkResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. POST /cotizaciones/{id}/duplicate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@router.post("/{cotizacion_id}/duplicate", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id)
|
||||||
|
if not original:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
|
||||||
|
servicios = await conn.fetch(
|
||||||
|
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
plan = await conn.fetchrow(
|
||||||
|
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with conn.transaction():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
new_id = _cuid()
|
||||||
|
new_numero = original["numero"] + "-COPY"
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "Cotizacion"
|
||||||
|
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
||||||
|
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
|
||||||
|
"clienteId", "asesorId", "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
|
||||||
|
""",
|
||||||
|
new_id,
|
||||||
|
new_numero,
|
||||||
|
original["fecha"],
|
||||||
|
original["vigencia"],
|
||||||
|
original["moneda"],
|
||||||
|
original["tipoCambio"],
|
||||||
|
original["proyecto"],
|
||||||
|
original["esquemaPago"],
|
||||||
|
original["incluirBonos"],
|
||||||
|
original["incluirFinanciamiento"],
|
||||||
|
original["observaciones"],
|
||||||
|
original["clienteId"],
|
||||||
|
original["asesorId"],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
for svc in servicios:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "ServicioCotizado"
|
||||||
|
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
|
||||||
|
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)
|
||||||
|
""",
|
||||||
|
_cuid(),
|
||||||
|
new_id,
|
||||||
|
svc["servicioCatalogoId"],
|
||||||
|
svc["fase"],
|
||||||
|
svc["tipoPago"],
|
||||||
|
svc["precio"],
|
||||||
|
svc["tiempoEntrega"],
|
||||||
|
svc["entregables"],
|
||||||
|
svc["notas"],
|
||||||
|
svc["seleccionado"],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
if plan:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "PlanBucefaloCotizacion"
|
||||||
|
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$6)
|
||||||
|
""",
|
||||||
|
_cuid(),
|
||||||
|
new_id,
|
||||||
|
plan["nivel"],
|
||||||
|
plan["precio"],
|
||||||
|
plan["seleccionado"],
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
full = await _fetch_cotizacion_full(pool, new_id)
|
||||||
|
return full
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""Export endpoints — PDF, Excel, CSV generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import PlainTextResponse, Response
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.models.export_ import ExportDraft
|
||||||
|
from app.models.common import ErrorResponse
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.services.calculators import bucefalo_precio, calcular_vigencia, sanitize_filename
|
||||||
|
from app.services.pdf_generator import generate_cotizacion_pdf
|
||||||
|
from app.services.excel_generator import generate_cotizacion_excel
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/export", tags=["Export"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_branding(db: Connection) -> dict[str, str]:
|
||||||
|
rows = await db.fetch('SELECT clave, valor FROM "Configuracion"')
|
||||||
|
return {r["clave"]: r["valor"] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) -> dict[str, Any]:
|
||||||
|
fecha = datetime.now()
|
||||||
|
if draft.fecha:
|
||||||
|
try:
|
||||||
|
fecha = datetime.fromisoformat(draft.fecha.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
fecha = datetime.strptime(draft.fecha, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
vigencia = calcular_vigencia(fecha)
|
||||||
|
logo_raw = branding.get("logo_base64", "")
|
||||||
|
logo_mime = None
|
||||||
|
logo_b64 = None
|
||||||
|
if logo_raw and ":" in logo_raw:
|
||||||
|
parts = logo_raw.split(":", 1)
|
||||||
|
logo_mime = parts[0]
|
||||||
|
logo_b64 = parts[1]
|
||||||
|
elif logo_raw:
|
||||||
|
logo_b64 = logo_raw
|
||||||
|
|
||||||
|
return {
|
||||||
|
"numero": "BORRADOR",
|
||||||
|
"clienteNombre": draft.clienteNombre,
|
||||||
|
"clienteEmpresa": draft.clienteEmpresa,
|
||||||
|
"asesorNombre": draft.asesorNombre,
|
||||||
|
"fecha": fecha,
|
||||||
|
"vigencia": vigencia,
|
||||||
|
"moneda": draft.moneda,
|
||||||
|
"tipoCambio": draft.tipoCambio,
|
||||||
|
"proyecto": draft.proyecto,
|
||||||
|
"esquemaPago": draft.esquemaPago,
|
||||||
|
"servicios": [
|
||||||
|
{
|
||||||
|
"nombre": s.nombre,
|
||||||
|
"fase": s.fase,
|
||||||
|
"tipoPago": s.tipoPago,
|
||||||
|
"precio": s.precio,
|
||||||
|
"tiempoEntrega": s.tiempoEntrega,
|
||||||
|
"entregables": s.entregables,
|
||||||
|
}
|
||||||
|
for s in draft.servicios
|
||||||
|
],
|
||||||
|
"planBucefaloNivel": draft.planBucefaloNivel,
|
||||||
|
"planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0,
|
||||||
|
"incluirBonos": draft.incluirBonos,
|
||||||
|
"colorPrimario": branding.get("color_primario", "#2563eb"),
|
||||||
|
"colorSecundario": branding.get("color_secundario", "#1e293b"),
|
||||||
|
"logoBase64": logo_b64,
|
||||||
|
"logoMime": logo_mime,
|
||||||
|
"configBancaria": branding,
|
||||||
|
"razonSocial": branding.get("razon_social", "Cotizador E3"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[str, Any] | None:
|
||||||
|
cot = await db.fetchrow(
|
||||||
|
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
|
||||||
|
u.name as asesor_nombre
|
||||||
|
FROM "Cotizacion" c
|
||||||
|
LEFT JOIN "Cliente" cl ON cl.id = c."clienteId"
|
||||||
|
LEFT JOIN "User" u ON u.id = c."asesorId"
|
||||||
|
WHERE c.id = $1""",
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
if not cot:
|
||||||
|
return None
|
||||||
|
|
||||||
|
servicios = await db.fetch(
|
||||||
|
"""SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables,
|
||||||
|
s.nombre
|
||||||
|
FROM "ServicioCotizado" sc
|
||||||
|
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
|
||||||
|
WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true
|
||||||
|
ORDER BY sc.fase, sc.id""",
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = await db.fetchrow(
|
||||||
|
'SELECT nivel, precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
branding = await _load_branding(db)
|
||||||
|
|
||||||
|
logo_raw = branding.get("logo_base64", "")
|
||||||
|
logo_mime = None
|
||||||
|
logo_b64 = None
|
||||||
|
if logo_raw and ":" in logo_raw:
|
||||||
|
parts = logo_raw.split(":", 1)
|
||||||
|
logo_mime = parts[0]
|
||||||
|
logo_b64 = parts[1]
|
||||||
|
elif logo_raw:
|
||||||
|
logo_b64 = logo_raw
|
||||||
|
|
||||||
|
servicios_list = []
|
||||||
|
for s in servicios:
|
||||||
|
ent = s["entregables"]
|
||||||
|
if isinstance(ent, str):
|
||||||
|
try:
|
||||||
|
ent = json.loads(ent)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
ent = []
|
||||||
|
servicios_list.append({
|
||||||
|
"nombre": s["nombre"],
|
||||||
|
"fase": s["fase"],
|
||||||
|
"tipoPago": s["tipoPago"],
|
||||||
|
"precio": float(s["precio"]),
|
||||||
|
"tiempoEntrega": s["tiempoEntrega"],
|
||||||
|
"entregables": ent or [],
|
||||||
|
})
|
||||||
|
|
||||||
|
plan_nivel = plan["nivel"] if plan else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"numero": cot["numero"],
|
||||||
|
"clienteNombre": cot["cliente_nombre"] or "",
|
||||||
|
"clienteEmpresa": cot["cliente_empresa"] or "",
|
||||||
|
"asesorNombre": cot["asesor_nombre"] or "",
|
||||||
|
"fecha": cot["fecha"],
|
||||||
|
"vigencia": cot["vigencia"],
|
||||||
|
"moneda": cot["moneda"],
|
||||||
|
"tipoCambio": cot["tipoCambio"],
|
||||||
|
"proyecto": cot["proyecto"],
|
||||||
|
"esquemaPago": cot["esquemaPago"],
|
||||||
|
"servicios": servicios_list,
|
||||||
|
"planBucefaloNivel": plan_nivel,
|
||||||
|
"planBucefaloPrecio": float(plan["precio"]) if plan else 0,
|
||||||
|
"incluirBonos": cot["incluirBonos"],
|
||||||
|
"colorPrimario": branding.get("color_primario", "#2563eb"),
|
||||||
|
"colorSecundario": branding.get("color_secundario", "#1e293b"),
|
||||||
|
"logoBase64": logo_b64,
|
||||||
|
"logoMime": logo_mime,
|
||||||
|
"configBancaria": branding,
|
||||||
|
"razonSocial": branding.get("razon_social", "Cotizador E3"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── PDF ENDPOINTS ──────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/pdf")
|
||||||
|
async def export_pdf_draft(
|
||||||
|
body: ExportDraft,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
branding = await _load_branding(db)
|
||||||
|
pdf_data = _build_pdf_data_from_draft(body, branding)
|
||||||
|
pdf_bytes = generate_cotizacion_pdf(pdf_data)
|
||||||
|
|
||||||
|
empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion"
|
||||||
|
filename = f"{sanitize_filename(empresa)} - BORRADOR.pdf"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pdf/{cotizacion_id}")
|
||||||
|
async def export_pdf_saved(
|
||||||
|
cotizacion_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
pdf_data = await _build_pdf_data_from_db(db, cotizacion_id)
|
||||||
|
if not pdf_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
|
||||||
|
pdf_bytes = generate_cotizacion_pdf(pdf_data)
|
||||||
|
|
||||||
|
empresa = pdf_data["clienteEmpresa"] or pdf_data["clienteNombre"]
|
||||||
|
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(pdf_data['numero'])}.pdf"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── EXCEL ENDPOINTS ────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/excel")
|
||||||
|
async def export_excel_draft(
|
||||||
|
body: ExportDraft,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
branding = await _load_branding(db)
|
||||||
|
excel_data = _build_pdf_data_from_draft(body, branding)
|
||||||
|
excel_bytes = generate_cotizacion_excel(excel_data, saved=False)
|
||||||
|
|
||||||
|
empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion"
|
||||||
|
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(body.clienteNombre)} - BORRADOR.xlsx"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=excel_bytes,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/excel/{cotizacion_id}")
|
||||||
|
async def export_excel_saved(
|
||||||
|
cotizacion_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
excel_data = await _build_pdf_data_from_db(db, cotizacion_id)
|
||||||
|
if not excel_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Cotización no encontrada")
|
||||||
|
|
||||||
|
excel_bytes = generate_cotizacion_excel(excel_data, saved=True)
|
||||||
|
|
||||||
|
empresa = excel_data["clienteEmpresa"] or excel_data["clienteNombre"]
|
||||||
|
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(excel_data['numero'])}.xlsx"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=excel_bytes,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CSV ENDPOINTS ──────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/catalogo", response_class=PlainTextResponse)
|
||||||
|
async def export_catalogo_csv(
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
rows = await db.fetch(
|
||||||
|
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
||||||
|
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
||||||
|
s.variante, s.orden, c.nombre AS categoria
|
||||||
|
FROM "ServicioCatalogo" s
|
||||||
|
LEFT JOIN "Categoria" c ON c.id = s."categoriaId"
|
||||||
|
WHERE s.activo = true
|
||||||
|
ORDER BY s.fase, s.orden"""
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow([
|
||||||
|
"Nombre", "Categoria", "Fase", "Tipo de Pago", "Precio Base",
|
||||||
|
"Tiempo de Entrega", "Entregables", "Variante",
|
||||||
|
])
|
||||||
|
for r in rows:
|
||||||
|
entregables = r["entregablesDefault"] or []
|
||||||
|
if isinstance(entregables, list):
|
||||||
|
entregables = " | ".join(entregables)
|
||||||
|
elif isinstance(entregables, str):
|
||||||
|
try:
|
||||||
|
ent_list = json.loads(entregables)
|
||||||
|
entregables = " | ".join(ent_list) if isinstance(ent_list, list) else entregables
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
writer.writerow([
|
||||||
|
r["nombre"], r["categoria"], r["fase"], r["tipoPago"],
|
||||||
|
r["precioBase"], r["tiempoEntrega"], entregables, r["variante"],
|
||||||
|
])
|
||||||
|
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=catalogo-servicios.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogo/plantilla", response_class=PlainTextResponse)
|
||||||
|
async def export_catalogo_template(db: Connection = Depends(get_db)):
|
||||||
|
cats = await db.fetch(
|
||||||
|
'SELECT nombre FROM "Categoria" WHERE activo = true ORDER BY orden'
|
||||||
|
)
|
||||||
|
cat_names = [r["nombre"] for r in cats]
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow([
|
||||||
|
"nombre", "descripcion", "fase", "tipoPago",
|
||||||
|
"precioBase", "tiempoEntrega", "entregablesDefault", "variante", "categoria",
|
||||||
|
])
|
||||||
|
writer.writerow([
|
||||||
|
"SEO On-Page", "Optimización on-page para buscadores", "Contenido y SEO", "mensual",
|
||||||
|
"2900", "7 - 14 dias", "Keyword research | On-page optimization", "", "SEO",
|
||||||
|
])
|
||||||
|
writer.writerow([
|
||||||
|
"", "", "", "", "", "", "", "", "",
|
||||||
|
])
|
||||||
|
writer.writerow([f"CATEGORIAS: {', '.join(cat_names)}"])
|
||||||
|
writer.writerow(["FASES: Auditoria, Setup e Infraestructura, Publicidad y Manejo, Contenido y SEO"])
|
||||||
|
writer.writerow(["TIPOS DE PAGO: unico, mensual"])
|
||||||
|
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=plantilla-catalogo.csv"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.models.export_ import (
|
||||||
|
FinanciamientoRequest,
|
||||||
|
FinanciamientoResponse,
|
||||||
|
BonoResponse,
|
||||||
|
PlanBucefaloResponse,
|
||||||
|
)
|
||||||
|
from app.models.common import ErrorResponse
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.services.calculators import (
|
||||||
|
calcular_financiamiento,
|
||||||
|
FINANCIAMIENTO_PLANES,
|
||||||
|
PLANES_BUCEFALO,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/financiamiento", tags=["Financiamiento"])
|
||||||
|
|
||||||
|
|
||||||
|
class PlanResponse(FinanciamientoResponse):
|
||||||
|
meses: int
|
||||||
|
tasa: float
|
||||||
|
comision: float
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/planes", response_model=list[PlanResponse])
|
||||||
|
async def get_planes(_auth: dict = Depends(require_auth)):
|
||||||
|
return [
|
||||||
|
PlanResponse(
|
||||||
|
meses=p["meses"],
|
||||||
|
tasa=p["tasa"],
|
||||||
|
comision=p["comision"],
|
||||||
|
**calcular_financiamiento(1000, p["meses"], p["tasa"], p["comision"]),
|
||||||
|
)
|
||||||
|
for p in sorted(FINANCIAMIENTO_PLANES, key=lambda x: x["meses"])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/calcular",
|
||||||
|
response_model=FinanciamientoResponse,
|
||||||
|
responses={400: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def calcular(
|
||||||
|
body: FinanciamientoRequest,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == body.meses), None)
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"No financing plan available for {body.meses} months",
|
||||||
|
)
|
||||||
|
if body.monto < plan["montoMinimo"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Minimum amount for {body.meses} months is ${plan['montoMinimo']}",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = calcular_financiamiento(body.monto, body.meses, plan["tasa"], plan["comision"])
|
||||||
|
return FinanciamientoResponse(
|
||||||
|
**result,
|
||||||
|
meses=body.meses,
|
||||||
|
tasa=plan["tasa"],
|
||||||
|
comision=plan["comision"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/simulacion/{cotizacion_id}",
|
||||||
|
response_model=list[FinanciamientoResponse],
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def simulacion(
|
||||||
|
cotizacion_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
cot = await db.fetchrow(
|
||||||
|
'SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id
|
||||||
|
)
|
||||||
|
if not cot:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Cotización not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await db.fetch(
|
||||||
|
'SELECT precio FROM "ServicioCotizado" WHERE "cotizacionId" = $1 AND seleccionado = true',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
total = sum(float(r["precio"]) for r in rows)
|
||||||
|
|
||||||
|
plan_buc = await db.fetchrow(
|
||||||
|
'SELECT precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||||
|
cotizacion_id,
|
||||||
|
)
|
||||||
|
if plan_buc:
|
||||||
|
total += float(plan_buc["precio"])
|
||||||
|
|
||||||
|
if total <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for plan in FINANCIAMIENTO_PLANES:
|
||||||
|
if total < plan["montoMinimo"]:
|
||||||
|
continue
|
||||||
|
result = calcular_financiamiento(total, plan["meses"], plan["tasa"], plan["comision"])
|
||||||
|
results.append(
|
||||||
|
FinanciamientoResponse(
|
||||||
|
**result,
|
||||||
|
meses=plan["meses"],
|
||||||
|
tasa=plan["tasa"],
|
||||||
|
comision=plan["comision"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.models.common import ErrorResponse, HealthResponse, APIInfoResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
async def health_check():
|
||||||
|
pool = await get_pool()
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute("SELECT 1")
|
||||||
|
db_status = "connected"
|
||||||
|
except Exception:
|
||||||
|
db_status = "disconnected"
|
||||||
|
|
||||||
|
return HealthResponse(
|
||||||
|
status="ok",
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
database=db_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api-info", response_model=APIInfoResponse)
|
||||||
|
async def api_info():
|
||||||
|
return APIInfoResponse(
|
||||||
|
name="Cotizador E3 API",
|
||||||
|
version="1.0.0",
|
||||||
|
description="API para el sistema de cotizaciones de Consultoría E3",
|
||||||
|
capabilities=[
|
||||||
|
"Gestión de cotizaciones",
|
||||||
|
"Catálogo de servicios",
|
||||||
|
"Planes Bucéfalo CRM",
|
||||||
|
"Exportación PDF/Excel",
|
||||||
|
"Autenticación JWT",
|
||||||
|
"Financiamiento Openpay",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, UploadFile
|
||||||
|
from asyncpg import Connection
|
||||||
|
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.auth import require_auth
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/import", tags=["Import"])
|
||||||
|
|
||||||
|
REQUIRED_COLUMNS = {"nombre", "fase", "tipoPago", "precioBase", "categoria"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/catalogo")
|
||||||
|
async def import_catalogo_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
db: Connection = Depends(get_db),
|
||||||
|
):
|
||||||
|
content = await file.read()
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
|
||||||
|
if not reader.fieldnames or not REQUIRED_COLUMNS.issubset(set(reader.fieldnames)):
|
||||||
|
missing = REQUIRED_COLUMNS - set(reader.fieldnames or [])
|
||||||
|
return {
|
||||||
|
"creados": 0,
|
||||||
|
"omitidos": 0,
|
||||||
|
"errores": [f"Missing required columns: {', '.join(missing)}"],
|
||||||
|
}
|
||||||
|
|
||||||
|
categorias = await db.fetch('SELECT id, nombre FROM "Categoria" WHERE activo = true')
|
||||||
|
cat_map = {r["nombre"]: r["id"] for r in categorias}
|
||||||
|
|
||||||
|
existing = await db.fetch('SELECT nombre FROM "ServicioCatalogo" WHERE activo = true')
|
||||||
|
existing_names = {r["nombre"] for r in existing}
|
||||||
|
|
||||||
|
creados = 0
|
||||||
|
omitidos = 0
|
||||||
|
errores: list[str] = []
|
||||||
|
|
||||||
|
async with db.transaction():
|
||||||
|
for i, row in enumerate(reader, start=2):
|
||||||
|
nombre = (row.get("nombre") or "").strip()
|
||||||
|
if not nombre:
|
||||||
|
omitidos += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if nombre in existing_names:
|
||||||
|
omitidos += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
cat_nombre = (row.get("categoria") or "").strip()
|
||||||
|
cat_id = cat_map.get(cat_nombre)
|
||||||
|
if not cat_id:
|
||||||
|
errores.append(f"Row {i}: category '{cat_nombre}' not found")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
fase = int(row.get("fase", "0"))
|
||||||
|
if fase not in (0, 1, 2, 3):
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errores.append(f"Row {i}: invalid fase '{row.get('fase')}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tipo_pago = (row.get("tipoPago") or "").strip()
|
||||||
|
if tipo_pago not in ("unico", "mensual"):
|
||||||
|
errores.append(f"Row {i}: invalid tipoPago '{tipo_pago}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
precio = float(row.get("precioBase", "0"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errores.append(f"Row {i}: invalid precioBase '{row.get('precioBase')}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
tiempo = (row.get("tiempoEntrega") or "7 - 14 dias").strip()
|
||||||
|
variante = (row.get("variante") or "").strip() or None
|
||||||
|
try:
|
||||||
|
orden = int(row.get("orden", "0"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
orden = 0
|
||||||
|
|
||||||
|
entregables_raw = (row.get("entregablesDefault") or "").strip()
|
||||||
|
entregables = [e.strip() for e in entregables_raw.split(";") if e.strip()] if entregables_raw else []
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO "ServicioCatalogo"
|
||||||
|
(id, nombre, descripcion, fase, "tipoPago", "precioBase",
|
||||||
|
"tiempoEntrega", "entregablesDefault", "categoriaId",
|
||||||
|
variante, orden, activo, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, now(), now())
|
||||||
|
""",
|
||||||
|
nombre,
|
||||||
|
(row.get("descripcion") or "").strip() or None,
|
||||||
|
fase,
|
||||||
|
tipo_pago,
|
||||||
|
precio,
|
||||||
|
tiempo,
|
||||||
|
entregables if entregables else None,
|
||||||
|
cat_id,
|
||||||
|
variante,
|
||||||
|
orden,
|
||||||
|
)
|
||||||
|
existing_names.add(nombre)
|
||||||
|
creados += 1
|
||||||
|
|
||||||
|
return {"creados": creados, "omitidos": omitidos, "errores": errores}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from app.database import get_pool
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.auth import require_auth
|
||||||
|
from app.models.paquete import (
|
||||||
|
PaqueteCreate,
|
||||||
|
PaqueteUpdate,
|
||||||
|
PaqueteResponse,
|
||||||
|
FasePaqueteResponse,
|
||||||
|
ManageAction,
|
||||||
|
)
|
||||||
|
from app.models.common import ErrorResponse, OkResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/paquetes", tags=["Paquetes"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
|
||||||
|
"""Build a PaqueteResponse dict with nested fases and servicios."""
|
||||||
|
paquete_id = paquete_row["id"]
|
||||||
|
|
||||||
|
fases_rows = await conn.fetch(
|
||||||
|
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
|
||||||
|
' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC',
|
||||||
|
paquete_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
fases: list[dict] = []
|
||||||
|
for fase in fases_rows:
|
||||||
|
servicios_rows = await conn.fetch(
|
||||||
|
'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",'
|
||||||
|
' sp."createdAt", sp."updatedAt",'
|
||||||
|
' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,'
|
||||||
|
' sc.fase AS sc_fase, sc."tipoPago" AS sc_tipoPago,'
|
||||||
|
' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,'
|
||||||
|
' sc."entregablesDefault" AS sc_entregablesDefault,'
|
||||||
|
' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,'
|
||||||
|
' sc.activo AS sc_activo, sc.orden AS sc_orden,'
|
||||||
|
' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,'
|
||||||
|
' cat.id AS cat_id, cat.nombre AS cat_nombre, cat.descripcion AS cat_descripcion,'
|
||||||
|
' cat.color AS cat_color, cat.activo AS cat_activo, cat.orden AS cat_orden,'
|
||||||
|
' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt'
|
||||||
|
' FROM "ServicioPaquete" sp'
|
||||||
|
' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"'
|
||||||
|
' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"'
|
||||||
|
' WHERE sp."fasePaqueteId" = $1',
|
||||||
|
fase["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
servicios: list[dict] = []
|
||||||
|
for s in servicios_rows:
|
||||||
|
cat = None
|
||||||
|
if s["cat_id"]:
|
||||||
|
cat = {
|
||||||
|
"id": s["cat_id"],
|
||||||
|
"nombre": s["cat_nombre"],
|
||||||
|
"descripcion": s["cat_descripcion"],
|
||||||
|
"color": s["cat_color"],
|
||||||
|
"activo": s["cat_activo"],
|
||||||
|
"orden": s["cat_orden"],
|
||||||
|
"createdAt": s["cat_createdAt"],
|
||||||
|
"updatedAt": s["cat_updatedAt"],
|
||||||
|
}
|
||||||
|
servicios.append({
|
||||||
|
"id": s["id"],
|
||||||
|
"servicioCatalogoId": s["servicioCatalogoId"],
|
||||||
|
"fasePaqueteId": s["fasePaqueteId"],
|
||||||
|
"createdAt": s["createdAt"],
|
||||||
|
"updatedAt": s["updatedAt"],
|
||||||
|
"servicio": {
|
||||||
|
"id": s["sc_id"],
|
||||||
|
"nombre": s["sc_nombre"],
|
||||||
|
"descripcion": s["sc_descripcion"],
|
||||||
|
"fase": s["sc_fase"],
|
||||||
|
"tipoPago": s["sc_tipoPago"],
|
||||||
|
"precioBase": s["sc_precioBase"],
|
||||||
|
"tiempoEntrega": s["sc_tiempoEntrega"],
|
||||||
|
"entregablesDefault": s["sc_entregablesDefault"],
|
||||||
|
"categoriaId": s["sc_categoriaId"],
|
||||||
|
"variante": s["sc_variante"],
|
||||||
|
"activo": s["sc_activo"],
|
||||||
|
"orden": s["sc_orden"],
|
||||||
|
"createdAt": s["sc_createdAt"],
|
||||||
|
"updatedAt": s["sc_updatedAt"],
|
||||||
|
"categoriaRel": cat,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
fases.append({
|
||||||
|
"id": fase["id"],
|
||||||
|
"paqueteId": fase["paqueteId"],
|
||||||
|
"nombre": fase["nombre"],
|
||||||
|
"orden": fase["orden"],
|
||||||
|
"createdAt": fase["createdAt"],
|
||||||
|
"updatedAt": fase["updatedAt"],
|
||||||
|
"servicios": servicios,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": paquete_row["id"],
|
||||||
|
"nombre": paquete_row["nombre"],
|
||||||
|
"descripcion": paquete_row["descripcion"],
|
||||||
|
"activo": paquete_row["activo"],
|
||||||
|
"createdAt": paquete_row["createdAt"],
|
||||||
|
"updatedAt": paquete_row["updatedAt"],
|
||||||
|
"fases": fases,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[PaqueteResponse])
|
||||||
|
async def list_paquetes(
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
rows = await conn.fetch(
|
||||||
|
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
|
||||||
|
' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC'
|
||||||
|
)
|
||||||
|
results: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
results.append(await _build_paquete_response(conn, dict(row)))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=PaqueteResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_paquete(
|
||||||
|
body: PaqueteCreate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
paquete_row = await conn.fetchrow(
|
||||||
|
'INSERT INTO "Paquete" (id, nombre, descripcion, "createdAt", "updatedAt")'
|
||||||
|
" VALUES (gen_random_uuid(), $1, $2, $3, $3)"
|
||||||
|
' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"',
|
||||||
|
body.nombre,
|
||||||
|
body.descripcion,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
paquete_id = paquete_row["id"]
|
||||||
|
for fase in body.fases:
|
||||||
|
await conn.execute(
|
||||||
|
'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")'
|
||||||
|
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)",
|
||||||
|
paquete_id,
|
||||||
|
fase.nombre,
|
||||||
|
fase.orden,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _build_paquete_response(conn, dict(paquete_row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{paquete_id}",
|
||||||
|
response_model=PaqueteResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def get_paquete(
|
||||||
|
paquete_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
|
||||||
|
' FROM "Paquete" WHERE id = $1',
|
||||||
|
paquete_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Paquete no encontrado",
|
||||||
|
)
|
||||||
|
return await _build_paquete_response(conn, dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{paquete_id}",
|
||||||
|
response_model=PaqueteResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def update_paquete(
|
||||||
|
paquete_id: str,
|
||||||
|
body: PaqueteUpdate,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "Paquete" WHERE id = $1', paquete_id
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Paquete no encontrado",
|
||||||
|
)
|
||||||
|
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if body.nombre is not None:
|
||||||
|
updates.append(f"nombre = ${idx}")
|
||||||
|
params.append(body.nombre)
|
||||||
|
idx += 1
|
||||||
|
if body.descripcion is not None:
|
||||||
|
updates.append(f"descripcion = ${idx}")
|
||||||
|
params.append(body.descripcion)
|
||||||
|
idx += 1
|
||||||
|
if body.activo is not None:
|
||||||
|
updates.append(f"activo = ${idx}")
|
||||||
|
params.append(body.activo)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
|
||||||
|
' FROM "Paquete" WHERE id = $1',
|
||||||
|
paquete_id,
|
||||||
|
)
|
||||||
|
return await _build_paquete_response(conn, dict(row))
|
||||||
|
|
||||||
|
updates.append(f'"updatedAt" = ${idx}')
|
||||||
|
params.append(datetime.now(timezone.utc))
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
params.append(paquete_id)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f'UPDATE "Paquete" SET {", ".join(updates)} WHERE id = ${idx}'
|
||||||
|
' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
return await _build_paquete_response(conn, dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{paquete_id}",
|
||||||
|
response_model=OkResponse,
|
||||||
|
responses={404: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def delete_paquete(
|
||||||
|
paquete_id: str,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await conn.execute(
|
||||||
|
'DELETE FROM "Paquete" WHERE id = $1', paquete_id
|
||||||
|
)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Paquete no encontrado",
|
||||||
|
)
|
||||||
|
return OkResponse(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{paquete_id}/manage",
|
||||||
|
responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
|
||||||
|
)
|
||||||
|
async def manage_paquete(
|
||||||
|
paquete_id: str,
|
||||||
|
body: ManageAction,
|
||||||
|
_auth: dict = Depends(require_auth),
|
||||||
|
conn=Depends(get_db),
|
||||||
|
):
|
||||||
|
paquete = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "Paquete" WHERE id = $1', paquete_id
|
||||||
|
)
|
||||||
|
if not paquete:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Paquete no encontrado",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
action = body.action
|
||||||
|
|
||||||
|
if action == "addFase":
|
||||||
|
if not body.nombre:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="El campo 'nombre' es requerido para addFase",
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")'
|
||||||
|
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)"
|
||||||
|
' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"',
|
||||||
|
paquete_id,
|
||||||
|
body.nombre,
|
||||||
|
body.orden or 0,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
return dict(row), status.HTTP_201_CREATED
|
||||||
|
|
||||||
|
if action == "updateFase":
|
||||||
|
if not body.faseId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="El campo 'faseId' es requerido para updateFase",
|
||||||
|
)
|
||||||
|
updates: list[str] = []
|
||||||
|
params: list = []
|
||||||
|
idx = 1
|
||||||
|
if body.nombre is not None:
|
||||||
|
updates.append(f"nombre = ${idx}")
|
||||||
|
params.append(body.nombre)
|
||||||
|
idx += 1
|
||||||
|
if body.orden is not None:
|
||||||
|
updates.append(f"orden = ${idx}")
|
||||||
|
params.append(body.orden)
|
||||||
|
idx += 1
|
||||||
|
if not updates:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
|
||||||
|
' FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
|
||||||
|
body.faseId,
|
||||||
|
paquete_id,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Fase no encontrada",
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
updates.append(f'"updatedAt" = ${idx}')
|
||||||
|
params.append(now)
|
||||||
|
idx += 1
|
||||||
|
params.extend([body.faseId, paquete_id])
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f'UPDATE "FasePaquete" SET {", ".join(updates)}'
|
||||||
|
f" WHERE id = ${idx} AND \"paqueteId\" = ${idx + 1}"
|
||||||
|
' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"',
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Fase no encontrada",
|
||||||
|
)
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
if action == "deleteFase":
|
||||||
|
if not body.faseId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="El campo 'faseId' es requerido para deleteFase",
|
||||||
|
)
|
||||||
|
result = await conn.execute(
|
||||||
|
'DELETE FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
|
||||||
|
body.faseId,
|
||||||
|
paquete_id,
|
||||||
|
)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Fase no encontrada",
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "addServicio":
|
||||||
|
if not body.servicioCatalogoId or not body.fasePaqueteId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para addServicio",
|
||||||
|
)
|
||||||
|
fase = await conn.fetchrow(
|
||||||
|
'SELECT id FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
|
||||||
|
body.fasePaqueteId,
|
||||||
|
paquete_id,
|
||||||
|
)
|
||||||
|
if not fase:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Fase no encontrada en este paquete",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
'INSERT INTO "ServicioPaquete" (id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt")'
|
||||||
|
" VALUES (gen_random_uuid(), $1, $2, $3, $3)"
|
||||||
|
' RETURNING id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt"',
|
||||||
|
body.servicioCatalogoId,
|
||||||
|
body.fasePaqueteId,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Este servicio ya está asignado a esta fase",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return dict(row), status.HTTP_201_CREATED
|
||||||
|
|
||||||
|
if action == "removeServicio":
|
||||||
|
if not body.servicioCatalogoId or not body.fasePaqueteId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para removeServicio",
|
||||||
|
)
|
||||||
|
result = await conn.execute(
|
||||||
|
'DELETE FROM "ServicioPaquete"'
|
||||||
|
' WHERE "servicioCatalogoId" = $1 AND "fasePaqueteId" = $2',
|
||||||
|
body.servicioCatalogoId,
|
||||||
|
body.fasePaqueteId,
|
||||||
|
)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Servicio no encontrado en esta fase",
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Acción desconocida: '{action}'. Acciones válidas: addFase, updateFase, deleteFase, addServicio, removeServicio",
|
||||||
|
)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
IVA_RATE = 0.16
|
||||||
|
|
||||||
|
FASES: dict[int, str] = {
|
||||||
|
0: "FASE 0 - Auditoria / Acompanamiento",
|
||||||
|
1: "FASE 1 - Setup e Infraestructura",
|
||||||
|
2: "FASE 2 - Publicidad y Manejo",
|
||||||
|
3: "FASE 3 - Contenido y SEO",
|
||||||
|
}
|
||||||
|
|
||||||
|
FASES_SHORT: dict[int, str] = {
|
||||||
|
0: "FASE 0 - Auditoria",
|
||||||
|
1: "FASE 1 - Setup e Infraestructura",
|
||||||
|
2: "FASE 2 - Publicidad y Manejo",
|
||||||
|
3: "FASE 3 - Contenido y SEO",
|
||||||
|
}
|
||||||
|
|
||||||
|
PLANES_BUCEFALO = [
|
||||||
|
{"nivel": "basico", "label": "Basico", "precio": 1000},
|
||||||
|
{"nivel": "estandar", "label": "Estandar", "precio": 3500},
|
||||||
|
{"nivel": "premium", "label": "Premium", "precio": 4500},
|
||||||
|
{"nivel": "empresarial", "label": "Empresarial", "precio": 7500},
|
||||||
|
]
|
||||||
|
|
||||||
|
ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"]
|
||||||
|
|
||||||
|
FINANCIAMIENTO_PLANES = [
|
||||||
|
{"meses": 3, "tasa": 0.077, "comision": 2.5, "montoMinimo": 300},
|
||||||
|
{"meses": 6, "tasa": 0.107, "comision": 2.5, "montoMinimo": 600},
|
||||||
|
{"meses": 9, "tasa": 0.137, "comision": 2.5, "montoMinimo": 900},
|
||||||
|
{"meses": 12, "tasa": 0.167, "comision": 2.5, "montoMinimo": 1200},
|
||||||
|
]
|
||||||
|
|
||||||
|
BONOS = [
|
||||||
|
{"id": "bono-1", "numero": 1, "titulo": "Servicio Centinela Web", "descripcion": "Monitoreo web 30 min/mes", "activo": True},
|
||||||
|
{"id": "bono-2", "numero": 2, "titulo": "Workshop Buyer Persona", "descripcion": "Taller de definición de buyer persona", "activo": True},
|
||||||
|
{"id": "bono-3", "numero": 3, "titulo": "Workshop Propuesta de Valor", "descripcion": "Taller de propuesta de valor", "activo": True},
|
||||||
|
{"id": "bono-4", "numero": 4, "titulo": "Membresia Premium", "descripcion": "Membresía premium por 1 año", "activo": True},
|
||||||
|
{"id": "bono-5", "numero": 5, "titulo": "Mes Gratis Bucefalo CRM", "descripcion": "Un mes gratis del CRM Bucefalo", "activo": True},
|
||||||
|
{"id": "bono-6", "numero": 6, "titulo": "Script de Ventas", "descripcion": "Script de ventas con 100+ complementos", "activo": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def bucefalo_precio(nivel: str) -> float:
|
||||||
|
for p in PLANES_BUCEFALO:
|
||||||
|
if p["nivel"] == nivel:
|
||||||
|
return float(p["precio"])
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def calcular_vigencia(fecha: datetime | date) -> datetime:
|
||||||
|
if isinstance(fecha, datetime):
|
||||||
|
vigencia = fecha.replace()
|
||||||
|
else:
|
||||||
|
vigencia = datetime.combine(fecha, datetime.min.time())
|
||||||
|
dias_habiles = 0
|
||||||
|
while dias_habiles < 15:
|
||||||
|
vigencia = vigencia.replace(day=vigencia.day + 1) if vigencia.day < 28 else vigencia + __import__("datetime").timedelta(days=1)
|
||||||
|
vigencia = vigencia + __import__("datetime").timedelta(days=1)
|
||||||
|
dia = vigencia.weekday()
|
||||||
|
if dia < 5:
|
||||||
|
dias_habiles += 1
|
||||||
|
return vigencia
|
||||||
|
|
||||||
|
|
||||||
|
def calcular_financiamiento(monto: float, meses: int, tasa: float, comision: float, iva: float = IVA_RATE) -> dict:
|
||||||
|
comision_total = (monto * comision) / 100
|
||||||
|
monto_con_comision = monto + comision_total
|
||||||
|
pago_mensual = monto_con_comision * (1 + tasa) / meses
|
||||||
|
iva_mensual = pago_mensual * iva
|
||||||
|
total_mensual = pago_mensual + iva_mensual
|
||||||
|
return {
|
||||||
|
"pagoMensual": round(pago_mensual, 2),
|
||||||
|
"ivaMensual": round(iva_mensual, 2),
|
||||||
|
"totalMensual": round(total_mensual, 2),
|
||||||
|
"comisionTotal": round(comision_total, 2),
|
||||||
|
"granTotal": round(total_mensual * meses, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generar_numero_cotizacion(iniciales: str, secuencia: int) -> str:
|
||||||
|
now = datetime.now()
|
||||||
|
yy = str(now.year)[-2:]
|
||||||
|
mm = f"{now.month:02d}"
|
||||||
|
seq = f"{secuencia:03d}"
|
||||||
|
return f"UJ{yy}{mm}{iniciales}{seq}"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
import re
|
||||||
|
name = re.sub(r"[^\w\s.\-]", "", name)
|
||||||
|
name = re.sub(r"\s+", " ", name)
|
||||||
|
return name.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def format_currency(amount: float) -> str:
|
||||||
|
return f"${amount:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(d: datetime | date) -> str:
|
||||||
|
return f"{d.day:02d}/{d.month:02d}/{d.year}"
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
from app.services.calculators import FASES_SHORT, bucefalo_precio
|
||||||
|
|
||||||
|
FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"}
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_fill(hex_color: str) -> PatternFill:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
return PatternFill(start_color=f"FF{h.upper()}", end_color=f"FF{h.upper()}", fill_type="solid")
|
||||||
|
|
||||||
|
|
||||||
|
def _argb(hex_color: str, alpha: str = "FF") -> str:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
if len(h) > 6:
|
||||||
|
h = h[:6]
|
||||||
|
return f"{alpha}{h.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _thin_border(color: str = "FFD1D5DB") -> Border:
|
||||||
|
side = Side(style="thin", color=color)
|
||||||
|
return Border(top=side, left=side, bottom=side, right=side)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_sheet_name(name: str) -> str:
|
||||||
|
name = name[:31]
|
||||||
|
return re.sub(r"[/\\*?\[\]:]", "", name)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_col_widths(ws, widths: list[tuple[str, float]]):
|
||||||
|
for col_letter, width in widths:
|
||||||
|
ws.column_dimensions[col_letter].width = width
|
||||||
|
|
||||||
|
|
||||||
|
def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes:
|
||||||
|
"""Generate a multi-sheet Excel workbook for a quotation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: dict with quotation data (same as PDF generator)
|
||||||
|
saved: if True, uses real quotation number instead of "BORRADOR"
|
||||||
|
"""
|
||||||
|
config_color = data.get("colorPrimario", "#2563eb")
|
||||||
|
PRIMARY = _argb(config_color)
|
||||||
|
PRIMARY_LIGHT = _argb(config_color, alpha="18")
|
||||||
|
SECONDARY = _argb(data.get("colorSecundario", "#1e293b"))
|
||||||
|
WHITE = "FFFFFFFF"
|
||||||
|
LIGHT_BG = "FFF8FAFC"
|
||||||
|
MUTED = "FF64748B"
|
||||||
|
BORDER_COLOR = "FFE2E8F0"
|
||||||
|
|
||||||
|
header_font = Font(bold=True, size=10, name="Calibri", color=WHITE)
|
||||||
|
label_font = Font(bold=True, size=10, name="Calibri", color=MUTED)
|
||||||
|
value_font = Font(size=10, name="Calibri")
|
||||||
|
bold_font = Font(bold=True, size=10, name="Calibri")
|
||||||
|
total_font = Font(bold=True, size=11, name="Calibri")
|
||||||
|
small_font = Font(italic=True, size=9, name="Calibri", color=MUTED)
|
||||||
|
fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY)
|
||||||
|
|
||||||
|
primary_fill = PatternFill(start_color=PRIMARY, end_color=PRIMARY, fill_type="solid")
|
||||||
|
primary_light_fill = PatternFill(start_color=PRIMARY_LIGHT, end_color=PRIMARY_LIGHT, fill_type="solid")
|
||||||
|
secondary_fill = PatternFill(start_color=SECONDARY, end_color=SECONDARY, fill_type="solid")
|
||||||
|
light_fill = PatternFill(start_color=LIGHT_BG, end_color=LIGHT_BG, fill_type="solid")
|
||||||
|
white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid")
|
||||||
|
|
||||||
|
razon_social = data.get("razonSocial", "Cotizador E3")
|
||||||
|
cliente_nombre = data.get("clienteNombre", "")
|
||||||
|
cliente_empresa = data.get("clienteEmpresa", "")
|
||||||
|
asesor_nombre = data.get("asesorNombre", "")
|
||||||
|
fecha = data.get("fecha", datetime.now())
|
||||||
|
vigencia = data.get("vigencia", datetime.now())
|
||||||
|
moneda = data.get("moneda", "MXN")
|
||||||
|
tipo_cambio = data.get("tipoCambio", "NA")
|
||||||
|
proyecto = data.get("proyecto", "MKT Digital")
|
||||||
|
esquema = data.get("esquemaPago", "Pago Unico/Mensual")
|
||||||
|
servicios = data.get("servicios", [])
|
||||||
|
plan_nivel = data.get("planBucefaloNivel")
|
||||||
|
numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR"
|
||||||
|
empresa = cliente_empresa or cliente_nombre
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
wb.properties.creator = "Cotizador E3"
|
||||||
|
|
||||||
|
# ── HOJA RESUMEN ──────────────────────────────
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "HOJA RESUMEN"
|
||||||
|
ws.sheet_view.showGridLines = False
|
||||||
|
|
||||||
|
_set_col_widths(ws, [
|
||||||
|
("A", 3), ("B", 22), ("C", 22), ("D", 16),
|
||||||
|
("E", 38), ("F", 5), ("G", 20), ("H", 5),
|
||||||
|
("I", 20), ("J", 5), ("K", 18),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Header banner
|
||||||
|
ws.merge_cells("B2:K2")
|
||||||
|
cell = ws["B2"]
|
||||||
|
cell.value = razon_social
|
||||||
|
cell.font = Font(bold=True, size=16, name="Calibri", color=WHITE)
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
for col in range(1, 12):
|
||||||
|
ws.cell(row=2, column=col).fill = primary_fill
|
||||||
|
|
||||||
|
ws.merge_cells("B3:K3")
|
||||||
|
cell = ws["B3"]
|
||||||
|
cell.value = numero_label
|
||||||
|
cell.font = Font(bold=True, size=11, name="Calibri", color=WHITE)
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
for col in range(1, 12):
|
||||||
|
ws.cell(row=3, column=col).fill = secondary_fill
|
||||||
|
|
||||||
|
# Info section
|
||||||
|
info_start = 5
|
||||||
|
info_rows = [
|
||||||
|
[("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")],
|
||||||
|
[("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)],
|
||||||
|
[("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)],
|
||||||
|
[("B", "Fecha:"), ("C", fecha), ("E", "Esquema de Pago:"), ("F", esquema)],
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, row_data in enumerate(info_rows):
|
||||||
|
r = info_start + i
|
||||||
|
for col_letter, label, is_value in [
|
||||||
|
(row_data[0][0], row_data[0][1], False),
|
||||||
|
(row_data[1][0], row_data[1][1], True),
|
||||||
|
(row_data[2][0], row_data[2][1], False),
|
||||||
|
(row_data[3][0], row_data[3][1], True),
|
||||||
|
]:
|
||||||
|
cell = ws[f"{col_letter}{r}"]
|
||||||
|
cell.value = label
|
||||||
|
cell.font = value_font if is_value else label_font
|
||||||
|
|
||||||
|
# Vigencia
|
||||||
|
vigencia_row = info_start + 4
|
||||||
|
ws[f"B{vigencia_row}"].value = "Vigencia:"
|
||||||
|
ws[f"B{vigencia_row}"].font = label_font
|
||||||
|
ws[f"C{vigencia_row}"].value = vigencia
|
||||||
|
ws[f"C{vigencia_row}"].number_format = "DD/MM/YYYY"
|
||||||
|
ws[f"C{vigencia_row}"].font = value_font
|
||||||
|
|
||||||
|
# Services table
|
||||||
|
table_start = info_start + 6
|
||||||
|
cols = ["B", "C", "D", "K"]
|
||||||
|
headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"]
|
||||||
|
|
||||||
|
ws.merge_cells(f"D{table_start}:J{table_start}")
|
||||||
|
for i, col_letter in enumerate(cols):
|
||||||
|
cell = ws[f"{col_letter}{table_start}"]
|
||||||
|
cell.value = headers[i]
|
||||||
|
cell.font = header_font
|
||||||
|
cell.fill = primary_fill
|
||||||
|
cell.alignment = Alignment(
|
||||||
|
horizontal="right" if i == len(cols) - 1 else "left",
|
||||||
|
vertical="center",
|
||||||
|
)
|
||||||
|
cell.border = _thin_border(PRIMARY)
|
||||||
|
ws.row_dimensions[table_start].height = 24
|
||||||
|
|
||||||
|
servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
|
||||||
|
servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
|
||||||
|
|
||||||
|
row = table_start + 1
|
||||||
|
current_fase = -1
|
||||||
|
|
||||||
|
for serv in servicios_unicos + servicios_mensuales:
|
||||||
|
fase = serv.get("fase", 0)
|
||||||
|
if fase != current_fase:
|
||||||
|
current_fase = fase
|
||||||
|
ws.merge_cells(f"B{row}:K{row}")
|
||||||
|
ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}")
|
||||||
|
ws[f"B{row}"].font = fase_font
|
||||||
|
for col in range(2, 12):
|
||||||
|
ws.cell(row=row, column=col).fill = primary_light_fill
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
row_bg = light_fill if row % 2 == 0 else white_fill
|
||||||
|
|
||||||
|
ws[f"B{row}"].value = ""
|
||||||
|
ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
|
||||||
|
ws[f"C{row}"].font = value_font
|
||||||
|
ws.merge_cells(f"D{row}:J{row}")
|
||||||
|
ws[f"D{row}"].value = serv.get("nombre", "")
|
||||||
|
ws[f"D{row}"].font = value_font
|
||||||
|
ws[f"K{row}"].value = serv.get("precio", 0)
|
||||||
|
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||||
|
ws[f"K{row}"].font = value_font
|
||||||
|
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||||
|
|
||||||
|
for col_letter in cols:
|
||||||
|
ws[f"{col_letter}{row}"].fill = row_bg
|
||||||
|
ws[f"{col_letter}{row}"].border = _thin_border(BORDER_COLOR)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
row += 1
|
||||||
|
total_unico = sum(s.get("precio", 0) for s in servicios_unicos)
|
||||||
|
ws.merge_cells(f"B{row}:J{row}")
|
||||||
|
ws[f"B{row}"].value = "Total Pago Unico"
|
||||||
|
ws[f"B{row}"].font = total_font
|
||||||
|
ws[f"K{row}"].value = total_unico
|
||||||
|
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||||
|
ws[f"K{row}"].font = total_font
|
||||||
|
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||||
|
for col_letter in cols:
|
||||||
|
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
|
||||||
|
ws[f"{col_letter}{row}"].fill = primary_light_fill
|
||||||
|
row += 1
|
||||||
|
ws.merge_cells(f"B{row}:J{row}")
|
||||||
|
ws[f"B{row}"].value = "(+ IVA)"
|
||||||
|
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
total_mensual = sum(s.get("precio", 0) for s in servicios_mensuales)
|
||||||
|
ws.merge_cells(f"B{row}:J{row}")
|
||||||
|
ws[f"B{row}"].value = "Total Pago Mensual"
|
||||||
|
ws[f"B{row}"].font = total_font
|
||||||
|
ws[f"K{row}"].value = total_mensual
|
||||||
|
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||||
|
ws[f"K{row}"].font = total_font
|
||||||
|
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||||
|
for col_letter in cols:
|
||||||
|
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
|
||||||
|
ws[f"{col_letter}{row}"].fill = primary_light_fill
|
||||||
|
row += 1
|
||||||
|
ws.merge_cells(f"B{row}:J{row}")
|
||||||
|
ws[f"B{row}"].value = "(+ IVA)"
|
||||||
|
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
|
||||||
|
row += 2
|
||||||
|
|
||||||
|
if plan_nivel:
|
||||||
|
ws.merge_cells(f"B{row}:J{row}")
|
||||||
|
ws[f"B{row}"].value = f"CRM Bucefalo - {plan_nivel.capitalize()}"
|
||||||
|
ws[f"B{row}"].font = bold_font
|
||||||
|
ws[f"K{row}"].value = bucefalo_precio(plan_nivel)
|
||||||
|
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||||
|
ws[f"K{row}"].font = bold_font
|
||||||
|
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||||
|
for col_letter in cols:
|
||||||
|
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
|
||||||
|
ws[f"{col_letter}{row}"].fill = primary_light_fill
|
||||||
|
row += 2
|
||||||
|
|
||||||
|
ws.merge_cells(f"B{row}:K{row}")
|
||||||
|
ws[f"B{row}"].value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
|
||||||
|
ws[f"B{row}"].font = small_font
|
||||||
|
|
||||||
|
# ── DETAIL SHEETS ──────────────────────────────
|
||||||
|
for serv in servicios:
|
||||||
|
safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
|
||||||
|
dw = wb.create_sheet(title=safe_name)
|
||||||
|
dw.sheet_view.showGridLines = False
|
||||||
|
|
||||||
|
_set_col_widths(dw, [
|
||||||
|
("A", 3), ("B", 35), ("C", 5), ("D", 15), ("E", 5),
|
||||||
|
("F", 35), ("G", 5), ("H", 15), ("I", 5), ("J", 18),
|
||||||
|
])
|
||||||
|
|
||||||
|
fase = serv.get("fase", 0)
|
||||||
|
dw.merge_cells("B1:J1")
|
||||||
|
dw["B1"].value = f"{FASES_MAP.get(fase, f'FASE {fase}')} \u2014 {serv.get('nombre', '')}"
|
||||||
|
dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE)
|
||||||
|
dw["B1"].alignment = Alignment(vertical="center")
|
||||||
|
for col in range(1, 11):
|
||||||
|
dw.cell(row=1, column=col).fill = primary_fill
|
||||||
|
dw.row_dimensions[1].height = 30
|
||||||
|
|
||||||
|
r = 3
|
||||||
|
dw[f"B{r}"].value = "Cliente:"
|
||||||
|
dw[f"B{r}"].font = label_font
|
||||||
|
dw[f"C{r}"].value = cliente_empresa or cliente_nombre
|
||||||
|
dw[f"C{r}"].font = bold_font
|
||||||
|
dw[f"F{r}"].value = "No. Cotizacion:"
|
||||||
|
dw[f"F{r}"].font = label_font
|
||||||
|
dw[f"G{r}"].value = numero_label
|
||||||
|
dw[f"G{r}"].font = bold_font
|
||||||
|
r += 2
|
||||||
|
|
||||||
|
dw[f"B{r}"].value = "Servicio:"
|
||||||
|
dw[f"B{r}"].font = label_font
|
||||||
|
dw[f"C{r}"].value = serv.get("nombre", "")
|
||||||
|
dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY)
|
||||||
|
dw[f"F{r}"].value = "Tiempo de Entrega:"
|
||||||
|
dw[f"F{r}"].font = label_font
|
||||||
|
dw[f"G{r}"].value = serv.get("tiempoEntrega", "")
|
||||||
|
dw[f"G{r}"].font = value_font
|
||||||
|
r += 2
|
||||||
|
|
||||||
|
# Entregables header
|
||||||
|
for col_letter in ["B", "I"]:
|
||||||
|
dw[f"{col_letter}{r}"].fill = primary_fill
|
||||||
|
dw[f"{col_letter}{r}"].font = header_font
|
||||||
|
dw[f"B{r}"].value = "Entregable"
|
||||||
|
r += 1
|
||||||
|
|
||||||
|
entregables = serv.get("entregables", [])
|
||||||
|
for i, ent in enumerate(entregables):
|
||||||
|
bg = light_fill if i % 2 == 1 else white_fill
|
||||||
|
dw[f"B{i + r}"].value = f"{i + 1}. {ent}"
|
||||||
|
dw[f"B{i + r}"].font = value_font
|
||||||
|
dw[f"B{i + r}"].fill = bg
|
||||||
|
dw[f"B{i + r}"].border = _thin_border(BORDER_COLOR)
|
||||||
|
r += len(entregables) + 1
|
||||||
|
|
||||||
|
# Total
|
||||||
|
for col_letter in ["B", "I"]:
|
||||||
|
dw[f"{col_letter}{r}"].fill = primary_light_fill
|
||||||
|
dw[f"{col_letter}{r}"].border = _thin_border(PRIMARY)
|
||||||
|
tipo_label = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
|
||||||
|
dw[f"B{r}"].value = f"Total Pago {tipo_label}"
|
||||||
|
dw[f"B{r}"].font = total_font
|
||||||
|
dw[f"I{r}"].value = serv.get("precio", 0)
|
||||||
|
dw[f"I{r}"].number_format = "$#,##0.00"
|
||||||
|
dw[f"I{r}"].font = total_font
|
||||||
|
dw[f"I{r}"].alignment = Alignment(horizontal="right")
|
||||||
|
r += 1
|
||||||
|
dw[f"B{r}"].value = "(+ IVA)"
|
||||||
|
dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
|
||||||
|
r += 2
|
||||||
|
|
||||||
|
dw.merge_cells(f"B{r}:J{r}")
|
||||||
|
dw[f"B{r}"].value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."
|
||||||
|
dw[f"B{r}"].font = small_font
|
||||||
|
r += 2
|
||||||
|
|
||||||
|
dw[f"B{r}"].value = razon_social
|
||||||
|
dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY)
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
return output.getvalue()
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""PDF Generator for Cotizador E3 — port of pdf-generator.ts using ReportLab."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import LETTER
|
||||||
|
from reportlab.lib.units import inch
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
|
||||||
|
from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio
|
||||||
|
|
||||||
|
PRIMARY_DEFAULT = "#2563eb"
|
||||||
|
DARK_DEFAULT = "#1e293b"
|
||||||
|
MUTED = "#64748b"
|
||||||
|
BORDER = "#cbd5e1"
|
||||||
|
LIGHT_BG = "#f1f5f9"
|
||||||
|
WHITE = "#ffffff"
|
||||||
|
|
||||||
|
PAGE_W, PAGE_H = LETTER
|
||||||
|
MARGIN_TOP = 45
|
||||||
|
MARGIN_BOTTOM = 55
|
||||||
|
MARGIN_LEFT = 50
|
||||||
|
MARGIN_RIGHT = 50
|
||||||
|
CONTENT_W = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb(hex_color: str) -> colors.Color:
|
||||||
|
h = hex_color.lstrip("#")
|
||||||
|
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||||
|
return colors.Color(r / 255, g / 255, b / 255)
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_currency(n: float) -> str:
|
||||||
|
return f"${n:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_date(d: datetime) -> str:
|
||||||
|
months = [
|
||||||
|
"enero", "febrero", "marzo", "abril", "mayo", "junio",
|
||||||
|
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
|
||||||
|
]
|
||||||
|
return f"{d.day} de {months[d.month - 1]} de {d.year}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_logo(logo_base64: str | None, logo_mime: str | None) -> ImageReader | None:
|
||||||
|
if not logo_base64:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(logo_base64)
|
||||||
|
return io.BytesIO(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
|
||||||
|
"""Generate a professional quotation PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: dict with keys matching CotizacionPDFData interface:
|
||||||
|
numero, clienteNombre, clienteEmpresa, asesorNombre, fecha, vigencia,
|
||||||
|
moneda, tipoCambio, proyecto, esquemaPago, servicios[],
|
||||||
|
planBucefaloNivel, planBucefaloPrecio, incluirBonos,
|
||||||
|
configBancaria{}, colorPrimario, colorSecundario, logoBase64, logoMime
|
||||||
|
"""
|
||||||
|
PRIMARY = data.get("colorPrimario") or PRIMARY_DEFAULT
|
||||||
|
DARK = data.get("colorSecundario") or DARK_DEFAULT
|
||||||
|
primary_rgb = _hex_to_rgb(PRIMARY)
|
||||||
|
dark_rgb = _hex_to_rgb(DARK)
|
||||||
|
muted_rgb = _hex_to_rgb(MUTED)
|
||||||
|
border_rgb = _hex_to_rgb(BORDER)
|
||||||
|
light_rgb = _hex_to_rgb(LIGHT_BG)
|
||||||
|
white_rgb = _hex_to_rgb(WHITE)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
c = canvas.Canvas(buf, pagesize=LETTER)
|
||||||
|
c.setTitle(f"Cotizacion {data.get('numero', '')}")
|
||||||
|
|
||||||
|
logo = _load_logo(data.get("logoBase64"), data.get("logoMime"))
|
||||||
|
|
||||||
|
servicios = data.get("servicios", [])
|
||||||
|
plan_nivel = data.get("planBucefaloNivel")
|
||||||
|
plan_precio = data.get("planBucefaloPrecio", 0)
|
||||||
|
incluir_bonos = data.get("incluirBonos", False)
|
||||||
|
cfg_bancaria = data.get("configBancaria") or {}
|
||||||
|
|
||||||
|
# ── PAGE 1: COVER ──────────────────────────────
|
||||||
|
c.setFillColor(white_rgb)
|
||||||
|
c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
|
||||||
|
|
||||||
|
y = PAGE_H - 80
|
||||||
|
if logo:
|
||||||
|
try:
|
||||||
|
c.drawImage(logo, MARGIN_LEFT, y - 20, width=65, height=65, preserveAspectRatio=True, mask="auto")
|
||||||
|
y -= 85
|
||||||
|
except Exception:
|
||||||
|
logo = None
|
||||||
|
|
||||||
|
if not logo:
|
||||||
|
c.setFont("Helvetica-Bold", 28)
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT, y, "UJ")
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 48, y - 15, "Uriel Jareth Consulting")
|
||||||
|
y -= 40
|
||||||
|
|
||||||
|
bar_y = y
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, bar_y, CONTENT_W, 4, fill=1, stroke=0)
|
||||||
|
|
||||||
|
c.setFont("Helvetica-Bold", 36)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT, bar_y - 40, "Cotizacion")
|
||||||
|
c.setFont("Helvetica", 18)
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT, bar_y - 65, data.get("numero", ""))
|
||||||
|
|
||||||
|
iy = bar_y - 100
|
||||||
|
cL = MARGIN_LEFT
|
||||||
|
cR = MARGIN_LEFT + CONTENT_W * 0.52
|
||||||
|
|
||||||
|
info_l = [
|
||||||
|
("Cliente", data.get("clienteNombre", "")),
|
||||||
|
("Empresa", data.get("clienteEmpresa", "")),
|
||||||
|
("Proyecto", data.get("proyecto", "")),
|
||||||
|
("Asesor", data.get("asesorNombre", "")),
|
||||||
|
]
|
||||||
|
info_r = [
|
||||||
|
("Fecha", _fmt_date(data.get("fecha", datetime.now()))),
|
||||||
|
("Vigencia", _fmt_date(data.get("vigencia", datetime.now()))),
|
||||||
|
("Moneda", data.get("moneda", "MXN")),
|
||||||
|
("Esquema", data.get("esquemaPago", "")),
|
||||||
|
]
|
||||||
|
|
||||||
|
for i in range(max(len(info_l), len(info_r))):
|
||||||
|
if i < len(info_l) and info_l[i][1]:
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(cL, iy, info_l[i][0])
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(cL, iy - 14, info_l[i][1])
|
||||||
|
if i < len(info_r) and info_r[i][1]:
|
||||||
|
c.setFont("Helvetica", 9)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(cR, iy, info_r[i][0])
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(cR, iy - 14, str(info_r[i][1]))
|
||||||
|
iy -= 32
|
||||||
|
|
||||||
|
# ── PAGE 2+: RESUMEN ──────────────────────────
|
||||||
|
c.showPage()
|
||||||
|
y = PAGE_H - MARGIN_TOP
|
||||||
|
|
||||||
|
if logo:
|
||||||
|
try:
|
||||||
|
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 15)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 12, y - 15, "Hoja Resumen")
|
||||||
|
y -= 28
|
||||||
|
|
||||||
|
cliente_ref = data.get("clienteEmpresa") or data.get("clienteNombre", "")
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT, y, f"En atencion a: {cliente_ref}")
|
||||||
|
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"No. Cotizacion: {data.get('numero', '')}")
|
||||||
|
y -= 11
|
||||||
|
c.drawString(MARGIN_LEFT, y, f"Asesor: {data.get('asesorNombre', '')}")
|
||||||
|
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"Fecha: {_fmt_date(data.get('fecha', datetime.now()))} | Moneda: {data.get('moneda', 'MXN')}")
|
||||||
|
y -= 18
|
||||||
|
|
||||||
|
col_nombre = MARGIN_LEFT
|
||||||
|
col_tipo = MARGIN_LEFT + CONTENT_W - 200
|
||||||
|
col_tiempo = MARGIN_LEFT + CONTENT_W - 120
|
||||||
|
col_precio = MARGIN_LEFT + CONTENT_W - 60
|
||||||
|
|
||||||
|
# Header row
|
||||||
|
c.setFillColor(light_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 16, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 7)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(col_nombre + 6, y + 3, "Servicio")
|
||||||
|
c.drawString(col_tipo + 4, y + 3, "Tipo")
|
||||||
|
c.drawString(col_tiempo + 4, y + 3, "Entrega")
|
||||||
|
c.drawRightString(col_precio + 60, y + 3, "Precio")
|
||||||
|
y -= 4
|
||||||
|
|
||||||
|
c.setStrokeColor(border_rgb)
|
||||||
|
c.setLineWidth(0.3)
|
||||||
|
c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y)
|
||||||
|
y -= 14
|
||||||
|
|
||||||
|
unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
|
||||||
|
mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
|
||||||
|
|
||||||
|
def _draw_section(srv_list, tipo_label, titulo_total, y_pos):
|
||||||
|
if not srv_list:
|
||||||
|
return y_pos
|
||||||
|
current_fase = -1
|
||||||
|
max_y = PAGE_H - MARGIN_BOTTOM - 5
|
||||||
|
|
||||||
|
for serv in srv_list:
|
||||||
|
fase = serv.get("fase", 0)
|
||||||
|
if fase != current_fase:
|
||||||
|
current_fase = fase
|
||||||
|
if y_pos - 20 < max_y - PAGE_H:
|
||||||
|
c.showPage()
|
||||||
|
y_pos = PAGE_H - MARGIN_TOP
|
||||||
|
c.setFillColor(light_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y_pos - 14, CONTENT_W, 14, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 7)
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 6, y_pos - 11, FASES_SHORT.get(fase, f"FASE {fase}"))
|
||||||
|
y_pos -= 18
|
||||||
|
|
||||||
|
c.setFont("Helvetica", 8)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
nombre = serv.get("nombre", "")
|
||||||
|
c.drawString(col_nombre + 6, y_pos - 10, nombre[:60])
|
||||||
|
c.setFont("Helvetica", 7)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(col_tipo + 4, y_pos - 10, tipo_label)
|
||||||
|
c.drawString(col_tiempo + 4, y_pos - 10, serv.get("tiempoEntrega", "")[:15])
|
||||||
|
c.setFont("Helvetica-Bold", 8)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0)))
|
||||||
|
y_pos -= 14
|
||||||
|
|
||||||
|
entregables = serv.get("entregables", [])
|
||||||
|
if entregables:
|
||||||
|
half = (len(entregables) + 1) // 2
|
||||||
|
for idx in range(half):
|
||||||
|
e1 = entregables[idx]
|
||||||
|
e2 = entregables[idx + half] if idx + half < len(entregables) else None
|
||||||
|
c.setFont("Helvetica", 6)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(col_nombre + 10, y_pos - 8, f"\u2022 {e1[:50]}")
|
||||||
|
if e2:
|
||||||
|
c.drawString(col_nombre + CONTENT_W * 0.48, y_pos - 8, f"\u2022 {e2[:50]}")
|
||||||
|
y_pos -= 9
|
||||||
|
|
||||||
|
c.setStrokeColor(_hex_to_rgb("#e5e7eb"))
|
||||||
|
c.setLineWidth(0.2)
|
||||||
|
c.line(col_nombre + 6, y_pos, MARGIN_LEFT + CONTENT_W, y_pos)
|
||||||
|
y_pos -= 6
|
||||||
|
|
||||||
|
total = sum(s.get("precio", 0) for s in srv_list)
|
||||||
|
y_pos -= 4
|
||||||
|
c.setFillColor(_hex_to_rgb("#f8fafc"))
|
||||||
|
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 18, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 8.5)
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.drawString(col_nombre + 6, y_pos + 3, titulo_total)
|
||||||
|
c.drawRightString(col_precio + 60, y_pos + 3, _fmt_currency(total))
|
||||||
|
y_pos -= 18
|
||||||
|
c.setFont("Helvetica", 6)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.drawString(col_nombre + 6, y_pos, "(Precios en Moneda Nacional, no incluyen IVA)")
|
||||||
|
y_pos -= 14
|
||||||
|
return y_pos
|
||||||
|
|
||||||
|
if unicos:
|
||||||
|
y = _draw_section(unicos, "Unico", "Total Pago Unico", y)
|
||||||
|
if mensuales:
|
||||||
|
y = _draw_section(mensuales, "Mensual", "Total Pago Mensual", y)
|
||||||
|
|
||||||
|
if plan_nivel:
|
||||||
|
label = plan_nivel.capitalize()
|
||||||
|
c.setFillColor(light_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 14, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 7.5)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(col_nombre + 6, y + 3, f"Plan Bucefalo CRM - {label}")
|
||||||
|
c.drawRightString(col_precio + 60, y + 3, _fmt_currency(plan_precio))
|
||||||
|
y -= 20
|
||||||
|
|
||||||
|
if incluir_bonos:
|
||||||
|
y -= 6
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - 10, 3, 10, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 9)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 10, y - 8, "Bonos (Pago en una exhibicion)")
|
||||||
|
y -= 20
|
||||||
|
bonos = [
|
||||||
|
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
|
||||||
|
"Bono 2: Workshop Estrategico de Buyer Persona",
|
||||||
|
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
|
||||||
|
"Bono 4: 1 ano de Membresia Premium",
|
||||||
|
"Bono 5: Un mes gratis de Bucefalo CRM",
|
||||||
|
"Bono 6: Script de Ventas con mas de 100 complementos",
|
||||||
|
]
|
||||||
|
for b in bonos:
|
||||||
|
c.setFont("Helvetica", 7)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 8, f"\u2713 {b}")
|
||||||
|
y -= 12
|
||||||
|
|
||||||
|
# ── T&C PAGE ──────────────────────────────────
|
||||||
|
c.showPage()
|
||||||
|
y = PAGE_H - MARGIN_TOP
|
||||||
|
|
||||||
|
if logo:
|
||||||
|
try:
|
||||||
|
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 15)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 12, y - 15, "Terminos y Condiciones")
|
||||||
|
y -= 30
|
||||||
|
|
||||||
|
def _section_title(title, yy):
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, yy - 9, 3, 9, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 9)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 10, yy - 8, title)
|
||||||
|
return yy - 20
|
||||||
|
|
||||||
|
def _draw_bullets(title, items, yy):
|
||||||
|
yy = _section_title(title, yy)
|
||||||
|
for t in items:
|
||||||
|
c.setFont("Helvetica", 7.5)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
# Simple text wrapping
|
||||||
|
words = t.split()
|
||||||
|
line = ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{line} {word}".strip()
|
||||||
|
if c.stringWidth(test, "Helvetica", 7.5) > CONTENT_W - 10:
|
||||||
|
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
|
||||||
|
yy -= 11
|
||||||
|
line = word
|
||||||
|
else:
|
||||||
|
line = test
|
||||||
|
if line:
|
||||||
|
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
|
||||||
|
yy -= 11
|
||||||
|
return yy - 8
|
||||||
|
|
||||||
|
y = _draw_bullets("Condiciones", [
|
||||||
|
"Esta cotizacion tiene una vigencia de 15 dias habiles.",
|
||||||
|
"Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.",
|
||||||
|
"El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.",
|
||||||
|
"Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.",
|
||||||
|
"Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.",
|
||||||
|
"Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.",
|
||||||
|
], y)
|
||||||
|
|
||||||
|
y = _draw_bullets("Que no incluye el proyecto?", [
|
||||||
|
"Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado.",
|
||||||
|
"Redaccion de entradas de Blog.",
|
||||||
|
"Integracion de Servicios de terceros ajenos a los cotizados.",
|
||||||
|
"Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads.",
|
||||||
|
"Creacion de Redes Sociales (En caso de requerir el servicio incluira un costo adicional).",
|
||||||
|
], y)
|
||||||
|
|
||||||
|
y = _draw_bullets("Notas", [
|
||||||
|
"El presente proyecto debera tener un responsable oficial.",
|
||||||
|
"La Hora Centinela tiene un precio de $700.00 MXN.",
|
||||||
|
"Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia. Si requiere los archivos editables, estos pueden ser adquiridos abonando una tarifa de liberacion (buy-out fee).",
|
||||||
|
"Si el proyecto se pausa por razones ajenas a Uriel Jareth Consulting, esto generara costo extra del 15% al 30% para retomar el proyecto.",
|
||||||
|
], y)
|
||||||
|
|
||||||
|
y -= 8
|
||||||
|
c.setFillColor(primary_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - 9, 3, 9, fill=1, stroke=0)
|
||||||
|
c.setFont("Helvetica-Bold", 9)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 10, y - 8, "Datos Bancarios")
|
||||||
|
y -= 20
|
||||||
|
|
||||||
|
razon_social = cfg_bancaria.get("razon_social", "URIEL JARETH ALVARADO ORTIZ")
|
||||||
|
rfc = cfg_bancaria.get("rfc", "AAOU970201SU7")
|
||||||
|
clabe_nac = cfg_bancaria.get("clabe_interbancaria", cfg_bancaria.get("cuenta_nacional", ""))
|
||||||
|
cuenta_nac = cfg_bancaria.get("cuenta_nacional", "")
|
||||||
|
banco_nac = "BBVA" if cuenta_nac else ""
|
||||||
|
|
||||||
|
box_h = 48
|
||||||
|
c.setFillColor(light_rgb)
|
||||||
|
c.setStrokeColor(border_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
|
||||||
|
c.setFont("Helvetica-Bold", 7)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Nacional")
|
||||||
|
c.setFont("Helvetica", 6.5)
|
||||||
|
if cuenta_nac:
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 24, f"Cuenta: {cuenta_nac}")
|
||||||
|
if clabe_nac:
|
||||||
|
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 24, f"CLABE: {clabe_nac}")
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 35, f"Razon Social: {razon_social}")
|
||||||
|
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, f"RFC: {rfc}")
|
||||||
|
if banco_nac:
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 46, f"Banco: {banco_nac}")
|
||||||
|
y -= box_h + 8
|
||||||
|
|
||||||
|
c.setFillColor(light_rgb)
|
||||||
|
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
|
||||||
|
c.setFont("Helvetica-Bold", 7)
|
||||||
|
c.setFillColor(dark_rgb)
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Internacional")
|
||||||
|
c.setFont("Helvetica", 6.5)
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 24, f"Beneficiario: {razon_social}")
|
||||||
|
c.drawString(MARGIN_LEFT + 8, y - 35, "Banco: BBVA Mexico")
|
||||||
|
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, "SWIFT: BCMRMXMMPYM")
|
||||||
|
|
||||||
|
# ── FOOTER ON ALL PAGES ──────────────────────
|
||||||
|
page_count = c.getPageNumber()
|
||||||
|
for i in range(1, page_count + 1):
|
||||||
|
c.setPageSize(LETTER)
|
||||||
|
c.setFont("Helvetica", 6)
|
||||||
|
c.setFillColor(muted_rgb)
|
||||||
|
c.setStrokeColor(border_rgb)
|
||||||
|
c.setLineWidth(0.5)
|
||||||
|
c.line(MARGIN_LEFT, MARGIN_BOTTOM - 10, MARGIN_LEFT + CONTENT_W, MARGIN_BOTTOM - 10)
|
||||||
|
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 22, "Uriel Jareth Consulting")
|
||||||
|
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 32, "urieljareth.com | [email protected] | (445) 182 9943")
|
||||||
|
|
||||||
|
c.save()
|
||||||
|
return buf.getvalue()
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
"""Cotizador E3 — FastAPI Application
|
||||||
|
|
||||||
|
REST API + MCP Server for digital marketing quotation management.
|
||||||
|
Optimized for n8n workflows and AI agents (OpenClaw, Claude, ChatGPT).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import close_db, init_db
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Cotizador E3 API",
|
||||||
|
description=(
|
||||||
|
"API REST para el sistema de cotizaciones de marketing digital de Consultoría E3. "
|
||||||
|
"Optimizada para integración con n8n y agentes de IA (OpenClaw, Claude, ChatGPT) "
|
||||||
|
"via MCP (Model Context Protocol)."
|
||||||
|
),
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
openapi_tags=[
|
||||||
|
{"name": "Health", "description": "Health check y información de la API"},
|
||||||
|
{"name": "Auth", "description": "Autenticación (API Key + JWT)"},
|
||||||
|
{"name": "Clientes", "description": "Gestión de clientes"},
|
||||||
|
{"name": "Catálogo", "description": "Catálogo de servicios de marketing digital"},
|
||||||
|
{"name": "Categorías", "description": "Categorías de servicios"},
|
||||||
|
{"name": "Cotizaciones", "description": "Cotizaciones — CRUD completo con servicios y plan CRM"},
|
||||||
|
{"name": "Paquetes", "description": "Paquetes de servicios con fases"},
|
||||||
|
{"name": "Configuración", "description": "Configuración de la empresa (key-value)"},
|
||||||
|
{"name": "Bonos", "description": "Bonos disponibles para cotizaciones"},
|
||||||
|
{"name": "Financiamiento", "description": "Planes y cálculo de financiamiento"},
|
||||||
|
{"name": "Export", "description": "Exportación de PDF, Excel y CSV"},
|
||||||
|
{"name": "Import", "description": "Importación masiva de servicios"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Error interno del servidor", "detail": str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Register routers
|
||||||
|
from app.routers import (
|
||||||
|
auth,
|
||||||
|
bonos,
|
||||||
|
catalogo,
|
||||||
|
categorias,
|
||||||
|
clientes,
|
||||||
|
configuracion,
|
||||||
|
cotizaciones,
|
||||||
|
export_,
|
||||||
|
financiamiento,
|
||||||
|
health,
|
||||||
|
import_,
|
||||||
|
paquetes,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(clientes.router)
|
||||||
|
app.include_router(catalogo.router)
|
||||||
|
app.include_router(categorias.router)
|
||||||
|
app.include_router(cotizaciones.router)
|
||||||
|
app.include_router(paquetes.router)
|
||||||
|
app.include_router(configuracion.router)
|
||||||
|
app.include_router(bonos.router)
|
||||||
|
app.include_router(financiamiento.router)
|
||||||
|
app.include_router(export_.router)
|
||||||
|
app.include_router(import_.router)
|
||||||
|
|
||||||
|
# Mount MCP server
|
||||||
|
try:
|
||||||
|
from app.mcp.server import server as mcp_server
|
||||||
|
|
||||||
|
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||||
|
|
||||||
|
@app.post("/mcp")
|
||||||
|
async def mcp_endpoint(request: Request):
|
||||||
|
"""MCP (Model Context Protocol) endpoint for AI agents."""
|
||||||
|
transport = StreamableHTTPServerTransport(mcp_server)
|
||||||
|
return await transport.handle_request(request)
|
||||||
|
|
||||||
|
@app.get("/mcp")
|
||||||
|
async def mcp_info():
|
||||||
|
"""MCP server info. Use POST for actual MCP communication."""
|
||||||
|
return {
|
||||||
|
"name": "cotizador-e3",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"protocol": "mcp",
|
||||||
|
"description": "MCP server for Cotizador E3 quotation system",
|
||||||
|
}
|
||||||
|
except ImportError:
|
||||||
|
# MCP SDK not installed, skip MCP mount
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"name": "Cotizador E3 API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
"openapi": "/openapi.json",
|
||||||
|
"health": "/health",
|
||||||
|
"mcp": "/mcp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("main:app", host=settings.API_HOST, port=settings.API_PORT, reload=True)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.34.0
|
||||||
|
asyncpg>=0.30.0
|
||||||
|
pydantic>=2.0
|
||||||
|
pydantic-settings>=2.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
python-multipart>=0.0.18
|
||||||
|
reportlab>=4.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
mcp>=1.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: cotizador-e3-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: cotizador_e3
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
container_name: cotizador-e3-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USER=postgres
|
||||||
|
- DB_PASSWORD=postgres
|
||||||
|
- DB_NAME=cotizador_e3
|
||||||
|
- API_KEY=${API_KEY:-change-me}
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change-me}
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
serverExternalPackages: ["pdfkit"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+2878
-21
File diff suppressed because it is too large
Load Diff
+24
-2
@@ -6,21 +6,43 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:migrate": "npx prisma migrate dev",
|
||||||
|
"db:seed": "npx tsx prisma/seed.ts",
|
||||||
|
"db:studio": "npx prisma studio",
|
||||||
|
"db:generate": "npx prisma generate"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "npx tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"pdfkit": "^0.18.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
seed: "npx tsx prisma/seed.ts",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'asesor',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Cliente" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"empresa" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"telefono" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Cliente_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Cotizacion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"numero" TEXT NOT NULL,
|
||||||
|
"fecha" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"vigencia" TIMESTAMP(3) NOT NULL,
|
||||||
|
"moneda" TEXT NOT NULL DEFAULT 'MXN',
|
||||||
|
"tipoCambio" TEXT NOT NULL DEFAULT 'NA',
|
||||||
|
"proyecto" TEXT NOT NULL DEFAULT 'MKT Digital',
|
||||||
|
"esquemaPago" TEXT NOT NULL DEFAULT 'Pago Unico/Mensual',
|
||||||
|
"estado" TEXT NOT NULL DEFAULT 'borrador',
|
||||||
|
"incluirBonos" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"incluirFinanciamiento" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"observaciones" TEXT,
|
||||||
|
"clienteId" TEXT NOT NULL,
|
||||||
|
"asesorId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Cotizacion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ServicioCatalogo" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"descripcion" TEXT,
|
||||||
|
"fase" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"tipoPago" TEXT NOT NULL DEFAULT 'unico',
|
||||||
|
"precioBase" DOUBLE PRECISION NOT NULL,
|
||||||
|
"tiempoEntrega" TEXT NOT NULL DEFAULT '4 - 10 dias',
|
||||||
|
"entregablesDefault" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"categoria" TEXT NOT NULL DEFAULT 'otros',
|
||||||
|
"variante" TEXT,
|
||||||
|
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orden" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ServicioCatalogo_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ServicioCotizado" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"cotizacionId" TEXT NOT NULL,
|
||||||
|
"servicioCatalogoId" TEXT NOT NULL,
|
||||||
|
"fase" INTEGER NOT NULL,
|
||||||
|
"tipoPago" TEXT NOT NULL,
|
||||||
|
"precio" DOUBLE PRECISION NOT NULL,
|
||||||
|
"tiempoEntrega" TEXT NOT NULL,
|
||||||
|
"entregables" JSONB NOT NULL DEFAULT '[]',
|
||||||
|
"notas" TEXT,
|
||||||
|
"seleccionado" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ServicioCotizado_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PlanBucefaloCotizacion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"cotizacionId" TEXT NOT NULL,
|
||||||
|
"nivel" TEXT NOT NULL DEFAULT 'basico',
|
||||||
|
"precio" DOUBLE PRECISION NOT NULL,
|
||||||
|
"seleccionado" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PlanBucefaloCotizacion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Configuracion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"clave" TEXT NOT NULL,
|
||||||
|
"valor" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Configuracion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Bono" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"numero" INTEGER NOT NULL,
|
||||||
|
"titulo" TEXT NOT NULL,
|
||||||
|
"descripcion" TEXT NOT NULL,
|
||||||
|
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "Bono_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FinanciamientoPlan" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"meses" INTEGER NOT NULL,
|
||||||
|
"tasa" DOUBLE PRECISION NOT NULL,
|
||||||
|
"comision" DOUBLE PRECISION NOT NULL,
|
||||||
|
"montoMinimo" DOUBLE PRECISION NOT NULL,
|
||||||
|
"iva" DOUBLE PRECISION NOT NULL DEFAULT 0.16,
|
||||||
|
|
||||||
|
CONSTRAINT "FinanciamientoPlan_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Cotizacion_numero_key" ON "Cotizacion"("numero");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PlanBucefaloCotizacion_cotizacionId_key" ON "PlanBucefaloCotizacion"("cotizacionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Configuracion_clave_key" ON "Configuracion"("clave");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FinanciamientoPlan_meses_key" ON "FinanciamientoPlan"("meses");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Cotizacion" ADD CONSTRAINT "Cotizacion_clienteId_fkey" FOREIGN KEY ("clienteId") REFERENCES "Cliente"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Cotizacion" ADD CONSTRAINT "Cotizacion_asesorId_fkey" FOREIGN KEY ("asesorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ServicioCotizado" ADD CONSTRAINT "ServicioCotizado_cotizacionId_fkey" FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ServicioCotizado" ADD CONSTRAINT "ServicioCotizado_servicioCatalogoId_fkey" FOREIGN KEY ("servicioCatalogoId") REFERENCES "ServicioCatalogo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PlanBucefaloCotizacion" ADD CONSTRAINT "PlanBucefaloCotizacion_cotizacionId_fkey" FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ServicioCatalogo" ADD COLUMN "categoriaId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Categoria" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"descripcion" TEXT,
|
||||||
|
"color" TEXT NOT NULL DEFAULT '#6b7280',
|
||||||
|
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"orden" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Categoria_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Paquete" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"descripcion" TEXT,
|
||||||
|
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Paquete_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FasePaquete" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"paqueteId" TEXT NOT NULL,
|
||||||
|
"nombre" TEXT NOT NULL,
|
||||||
|
"orden" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FasePaquete_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ServicioPaquete" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"servicioCatalogoId" TEXT NOT NULL,
|
||||||
|
"fasePaqueteId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ServicioPaquete_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Categoria_nombre_key" ON "Categoria"("nombre");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ServicioPaquete_servicioCatalogoId_fasePaqueteId_key" ON "ServicioPaquete"("servicioCatalogoId", "fasePaqueteId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FasePaquete" ADD CONSTRAINT "FasePaquete_paqueteId_fkey" FOREIGN KEY ("paqueteId") REFERENCES "Paquete"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ServicioCatalogo" ADD CONSTRAINT "ServicioCatalogo_categoriaId_fkey" FOREIGN KEY ("categoriaId") REFERENCES "Categoria"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ServicioPaquete" ADD CONSTRAINT "ServicioPaquete_servicioCatalogoId_fkey" FOREIGN KEY ("servicioCatalogoId") REFERENCES "ServicioCatalogo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ServicioPaquete" ADD CONSTRAINT "ServicioPaquete_fasePaqueteId_fkey" FOREIGN KEY ("fasePaqueteId") REFERENCES "FasePaquete"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ServicioCatalogo" DROP COLUMN "categoria";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String
|
||||||
|
role String @default("asesor")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cotizaciones Cotizacion[] @relation("AsesorCotizaciones")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cliente {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
empresa String?
|
||||||
|
email String?
|
||||||
|
telefono String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cotizaciones Cotizacion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Cotizacion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
numero String @unique
|
||||||
|
fecha DateTime @default(now())
|
||||||
|
vigencia DateTime
|
||||||
|
moneda String @default("MXN")
|
||||||
|
tipoCambio String @default("NA")
|
||||||
|
proyecto String @default("MKT Digital")
|
||||||
|
esquemaPago String @default("Pago Unico/Mensual")
|
||||||
|
estado String @default("borrador")
|
||||||
|
incluirBonos Boolean @default(false)
|
||||||
|
incluirFinanciamiento Boolean @default(false)
|
||||||
|
observaciones String?
|
||||||
|
clienteId String
|
||||||
|
asesorId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
|
asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id])
|
||||||
|
servicios ServicioCotizado[]
|
||||||
|
planBucefalo PlanBucefaloCotizacion?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Categoria {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String @unique
|
||||||
|
descripcion String?
|
||||||
|
color String @default("#6b7280")
|
||||||
|
activo Boolean @default(true)
|
||||||
|
orden Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
servicios ServicioCatalogo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Paquete {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
fases FasePaquete[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model FasePaquete {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
paqueteId String
|
||||||
|
nombre String
|
||||||
|
orden Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
paquete Paquete @relation(fields: [paqueteId], references: [id], onDelete: Cascade)
|
||||||
|
servicios ServicioPaquete[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ServicioCatalogo {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
nombre String
|
||||||
|
descripcion String?
|
||||||
|
fase Int @default(1)
|
||||||
|
tipoPago String @default("unico")
|
||||||
|
precioBase Float
|
||||||
|
tiempoEntrega String @default("4 - 10 dias")
|
||||||
|
entregablesDefault Json @default("[]")
|
||||||
|
categoriaId String?
|
||||||
|
variante String?
|
||||||
|
activo Boolean @default(true)
|
||||||
|
orden Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
servicios ServicioCotizado[]
|
||||||
|
categoriaRel Categoria? @relation(fields: [categoriaId], references: [id])
|
||||||
|
paquetes ServicioPaquete[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ServicioPaquete {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
servicioCatalogoId String
|
||||||
|
fasePaqueteId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
servicio ServicioCatalogo @relation(fields: [servicioCatalogoId], references: [id], onDelete: Cascade)
|
||||||
|
fasePaquete FasePaquete @relation(fields: [fasePaqueteId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([servicioCatalogoId, fasePaqueteId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ServicioCotizado {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cotizacionId String
|
||||||
|
servicioCatalogoId String
|
||||||
|
fase Int
|
||||||
|
tipoPago String
|
||||||
|
precio Float
|
||||||
|
tiempoEntrega String
|
||||||
|
entregables Json @default("[]")
|
||||||
|
notas String?
|
||||||
|
seleccionado Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
|
||||||
|
servicioCatalogo ServicioCatalogo @relation(fields: [servicioCatalogoId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PlanBucefaloCotizacion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
cotizacionId String @unique
|
||||||
|
nivel String @default("basico")
|
||||||
|
precio Float
|
||||||
|
seleccionado Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Configuracion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
clave String @unique
|
||||||
|
valor String
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Bono {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
numero Int
|
||||||
|
titulo String
|
||||||
|
descripcion String
|
||||||
|
activo Boolean @default(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
model FinanciamientoPlan {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
meses Int @unique
|
||||||
|
tasa Float
|
||||||
|
comision Float
|
||||||
|
montoMinimo Float
|
||||||
|
iva Float @default(0.16)
|
||||||
|
}
|
||||||
+760
@@ -0,0 +1,760 @@
|
|||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
|
||||||
|
const adapter = new PrismaPg({
|
||||||
|
host: "localhost",
|
||||||
|
port: 5432,
|
||||||
|
user: "postgres",
|
||||||
|
password: "postgres",
|
||||||
|
database: "cotizador_e3",
|
||||||
|
});
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
const adminHash = await hash("cee564F1.", 10);
|
||||||
|
const asesorHash = await hash("asesor123", 10);
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: "[email protected]" },
|
||||||
|
update: { password: adminHash },
|
||||||
|
create: {
|
||||||
|
email: "[email protected]",
|
||||||
|
password: adminHash,
|
||||||
|
name: "Uriel Jareth Alvarado Ortiz",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: "[email protected]" },
|
||||||
|
update: { password: asesorHash },
|
||||||
|
create: {
|
||||||
|
email: "[email protected]",
|
||||||
|
password: asesorHash,
|
||||||
|
name: "Lorena Soto",
|
||||||
|
role: "asesor",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CATEGORIAS (must be created BEFORE servicios for FK) ──
|
||||||
|
const categorias = [
|
||||||
|
{ nombre: "SEO", descripcion: "Optimizacion para motores de busqueda", color: "#10b981", orden: 1 },
|
||||||
|
{ nombre: "Marketing", descripcion: "Estrategia y marketing digital", color: "#6366f1", orden: 2 },
|
||||||
|
{ nombre: "Paid Media", descripcion: "Publicidad pagada en plataformas digitales", color: "#f59e0b", orden: 3 },
|
||||||
|
{ nombre: "Desarrollo Web", descripcion: "Diseno y desarrollo de sitios web y ecommerce", color: "#3b82f6", orden: 4 },
|
||||||
|
{ nombre: "Automatizaciones", descripcion: "Automatizacion de procesos y flujos de trabajo", color: "#8b5cf6", orden: 5 },
|
||||||
|
{ nombre: "CRM", descripcion: "Gestion de relaciones con clientes", color: "#ec4899", orden: 6 },
|
||||||
|
{ nombre: "Desarrollo Personalizado", descripcion: "Soluciones a medida y desarrollo custom", color: "#14b8a6", orden: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const catMap: Record<string, string> = {};
|
||||||
|
for (const cat of categorias) {
|
||||||
|
const created = await prisma.categoria.upsert({
|
||||||
|
where: { nombre: cat.nombre },
|
||||||
|
update: { color: cat.color, orden: cat.orden },
|
||||||
|
create: cat,
|
||||||
|
});
|
||||||
|
catMap[cat.nombre] = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catAlias: Record<string, string> = {
|
||||||
|
auditoria: "Marketing",
|
||||||
|
estrategia: "Marketing",
|
||||||
|
diseno: "Desarrollo Web",
|
||||||
|
sitio_web: "Desarrollo Web",
|
||||||
|
ecommerce: "Desarrollo Web",
|
||||||
|
ads: "Paid Media",
|
||||||
|
redes: "Marketing",
|
||||||
|
seo: "SEO",
|
||||||
|
contenido: "Marketing",
|
||||||
|
crm: "CRM",
|
||||||
|
acompanamiento: "Marketing",
|
||||||
|
otros: "Desarrollo Personalizado",
|
||||||
|
};
|
||||||
|
|
||||||
|
const servicios = [
|
||||||
|
{
|
||||||
|
nombre: "Auditoria en Redes Sociales",
|
||||||
|
descripcion: "Auditoria completa de presencia en redes sociales",
|
||||||
|
fase: 0,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2990,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "auditoria",
|
||||||
|
orden: 1,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Revision de presencia en redes sociales",
|
||||||
|
"Analisis de contenido publicado",
|
||||||
|
"Evaluacion de engagement",
|
||||||
|
"Documento de Evaluacion Total de Auditoria",
|
||||||
|
"Junta de Resultados",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Auditoria en Sitio Web",
|
||||||
|
descripcion: "Auditoria tecnica y de rendimiento de sitio web",
|
||||||
|
fase: 0,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2990,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "auditoria",
|
||||||
|
orden: 2,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Seguridad de dominio (vulnerabilidades)",
|
||||||
|
"Velocidad de sitio web (core web vital)",
|
||||||
|
"Diseno responsivo",
|
||||||
|
"Performance del sitio",
|
||||||
|
"Revison de SEO On Page y Off Page",
|
||||||
|
"Chequeo de listado del sitio en listas negras",
|
||||||
|
"Chequeo de Malware y Codigo Malicioso",
|
||||||
|
"Revision de Firewall en el Sitio",
|
||||||
|
"Recomendaciones SEO",
|
||||||
|
"Revision de Google Search Console",
|
||||||
|
"Revision de pixeles de Google y FB Ads",
|
||||||
|
"Documento de Evaluacion Total de Auditoria",
|
||||||
|
"Junta de Resultados",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Auditoria en Ecommerce",
|
||||||
|
descripcion: "Auditoria completa de tienda en linea",
|
||||||
|
fase: 0,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2990,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "auditoria",
|
||||||
|
orden: 3,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Revision de Seguridad de dominio",
|
||||||
|
"Revision de Certificado SSL",
|
||||||
|
"Revision de Metodos de Pago",
|
||||||
|
"Revision de Metodos de Envio",
|
||||||
|
"Revision de Velocidad de Sitio Web",
|
||||||
|
"Revision de Categorizacion de Productos",
|
||||||
|
"Revision de Filtrado de Productos",
|
||||||
|
"Revision de Carritos Abandonados",
|
||||||
|
"Revision de Correos Transaccionales",
|
||||||
|
"Revision de Pasos de Checkout",
|
||||||
|
"Documento de Evaluacion Total de Auditoria",
|
||||||
|
"Junta de Resultados",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Auditoria de cuentas Google Ads y Meta Ads",
|
||||||
|
descripcion: "Auditoria de cuentas publicitarias",
|
||||||
|
fase: 0,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2990,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "auditoria",
|
||||||
|
orden: 4,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Revision de Configuracion General de Google Ads",
|
||||||
|
"Revision de Configuracion de Facebook/Instagram Ads",
|
||||||
|
"Revision de la Segmentacion de Publicos Personalizados",
|
||||||
|
"Revision de Configuracion de Campanas",
|
||||||
|
"Revision de Configuracion de Grupo de Anuncios",
|
||||||
|
"Revision de Conversiones y Eventos Personalizados",
|
||||||
|
"Revision de Integracion e Implementacion de Pixeles",
|
||||||
|
"Revision de Estrategia de Presupuesto",
|
||||||
|
"Revision de Anuncios Graficos",
|
||||||
|
"Revision de Extensiones en los Anuncios",
|
||||||
|
"Documento de Evaluacion Total de Auditoria",
|
||||||
|
"Junta de Resultados",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Estudio Digital de Mercado",
|
||||||
|
descripcion: "Estudio completo de mercado digital",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 4900,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "estrategia",
|
||||||
|
orden: 5,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Estudio de Competencia Organica",
|
||||||
|
"Estudio de Competencia Pagada",
|
||||||
|
"Estudio de Demanda",
|
||||||
|
"Estudio de Tendencia",
|
||||||
|
"Estudio de Segmentacion de Mercado",
|
||||||
|
"Junta de Analisis de Resultados y Conclusiones",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Diseno de Identidad Corporativa",
|
||||||
|
descripcion: "Creacion de identidad corporativa completa",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 11900,
|
||||||
|
tiempoEntrega: "7 - 15 dias",
|
||||||
|
categoria: "diseno",
|
||||||
|
orden: 6,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Creacion de Logotipo y Favicon",
|
||||||
|
"Manual de Identidad Corporativa (colores, tipografias, usos)",
|
||||||
|
"Plantillas para Redes Sociales (posts, historias, carruseles)",
|
||||||
|
"Plantilla para portada de Redes Sociales",
|
||||||
|
"Entrega de archivos editables",
|
||||||
|
"Entrega de Mockups",
|
||||||
|
"Aplicativos Digitales (firma electronica, diapositivas, wallpaper)",
|
||||||
|
"Aplicativos Fisicos (tarjetas, hojas membretadas)",
|
||||||
|
"Presentacion de Propuestas",
|
||||||
|
"Tres juntas de revision",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Diseno de Sitio Web (WordPress)",
|
||||||
|
descripcion: "Diseno y desarrollo de sitio web en WordPress",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 35000,
|
||||||
|
tiempoEntrega: "4 - 8 semanas",
|
||||||
|
categoria: "sitio_web",
|
||||||
|
variante: "wordpress",
|
||||||
|
orden: 7,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Desarrollo y Estructuracion Jerarquica de Sitio Web",
|
||||||
|
"Creacion de Paginas Basicas (TyC, PyP, Gracias, 404)",
|
||||||
|
"Optimizacion Avanzada SEO en cada Pagina",
|
||||||
|
"Sitio adaptado a ordenadores, tabletas y celulares (responsive)",
|
||||||
|
"Configuracion Avanzada de Velocidad de Carga",
|
||||||
|
"Diseno y Optimizacion de elementos graficos",
|
||||||
|
"Integracion y Configuracion de Contacto por WhatsApp y Llamada",
|
||||||
|
"Configuracion de Plugins de Seguridad",
|
||||||
|
"Capacitacion Basica de Sitio",
|
||||||
|
"Poliza de mantenimiento",
|
||||||
|
"Respaldo de seguridad semanal",
|
||||||
|
"Creacion y Optimizacion de Blog optimizado para SEO",
|
||||||
|
"Integracion de Formulario",
|
||||||
|
"Documento Estadisticas de Sitio Web (mensual)",
|
||||||
|
"Indexacion en Motores de Busqueda",
|
||||||
|
"Configuracion de Google Analytics 4, Tag Manager, Google Ads, Pixel y Capi de Facebook",
|
||||||
|
"Configuracion de Google Search Console",
|
||||||
|
"Revision de TyC, Politicas y Privacidad",
|
||||||
|
"Creacion de Textos Comerciales",
|
||||||
|
"Soporte de Primer y Segundo Nivel",
|
||||||
|
"Dominio, Certificado SSL, Hosting Incluido (5GB) - 1 Ano",
|
||||||
|
"Incluye tres revisiones del Proyecto",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Diseno de Ecommerce (WooCommerce)",
|
||||||
|
descripcion: "Tienda en linea con WooCommerce",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 19900,
|
||||||
|
tiempoEntrega: "4 - 8 semanas",
|
||||||
|
categoria: "ecommerce",
|
||||||
|
variante: "woocommerce",
|
||||||
|
orden: 8,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Desarrollo y Estructuracion de Ecommerce",
|
||||||
|
"Creacion de Paginas Basicas (TyC, PyP, Gracias, 404)",
|
||||||
|
"Optimizacion Avanzada SEO en cada Pagina de Productos y Categorias",
|
||||||
|
"Diseno Responsivo (UI / UX)",
|
||||||
|
"Configuracion Avanzada de Velocidad de Carga",
|
||||||
|
"Diseno y Optimizacion de elementos graficos para cada Pagina",
|
||||||
|
"Integracion y Configuracion de Contacto por WhatsApp y Llamada",
|
||||||
|
"Integracion de Formulario",
|
||||||
|
"Integracion de Plugins de Seguridad",
|
||||||
|
"Capacitacion Basica de Sitio",
|
||||||
|
"Integracion de Metodos de Pago",
|
||||||
|
"Integracion de Metodos de Envio",
|
||||||
|
"Configuracion Jerarquica de Categoria por Nivel",
|
||||||
|
"Configuracion de Enlazado de Categorias",
|
||||||
|
"Configuracion de Carritos Abandonados",
|
||||||
|
"Documento Estadisticas de Sitio Web (mensual)",
|
||||||
|
"Indexacion en Motores de Busqueda",
|
||||||
|
"Configuracion de Google Analytics 4, Tag Manager, Google Ads, Pixel y Capi de Facebook para Ecommerce",
|
||||||
|
"Configuracion de Google Search Console",
|
||||||
|
"Incluye tres revisiones del Proyecto",
|
||||||
|
"Dominio, Certificado SSL, Hosting Incluido (5GB) - 1 Ano",
|
||||||
|
"Creacion de Textos Comerciales",
|
||||||
|
"Desarrollo y Estructuracion de Blog",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Diseno Tienda en Linea (Shopify)",
|
||||||
|
descripcion: "Tienda en linea con Shopify",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 21900,
|
||||||
|
tiempoEntrega: "4 - 8 semanas",
|
||||||
|
categoria: "ecommerce",
|
||||||
|
variante: "shopify",
|
||||||
|
orden: 9,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Desarrollo y Estructuracion de Ecommerce",
|
||||||
|
"Configuracion de Paginas Basicas (TyC, AyP, Contacto, Nosotros, 404)",
|
||||||
|
"Configuracion de Paginas de Ecommerce (Mi cuenta, Inicio de sesion, Politicas)",
|
||||||
|
"Configuracion y Personalizacion de Template",
|
||||||
|
"Optimizacion SEO en cada Pagina de Productos y Categorias",
|
||||||
|
"Diseno Responsivo (UI / UX)",
|
||||||
|
"Configuracion de Velocidad de Carga en Sitio Web",
|
||||||
|
"Diseno y Optimizacion de elementos graficos para cada Pagina",
|
||||||
|
"Integracion y Configuracion de App de Contacto por WhatsApp",
|
||||||
|
"Integracion de Apps para Metodos de Pago",
|
||||||
|
"Integracion de Apps para Metodos de Envio",
|
||||||
|
"Configuracion y Estructura de Menu Principal",
|
||||||
|
"Soporte de Primer y Segundo Nivel",
|
||||||
|
"Creacion de Categorias de Tienda",
|
||||||
|
"Configuracion de Correos Base para Ecommerce",
|
||||||
|
"Configuracion de Carritos Abandonados",
|
||||||
|
"Indexacion en Motores de Busqueda",
|
||||||
|
"Configuracion de Google Search Console",
|
||||||
|
"Implementacion de Google Analytics 4",
|
||||||
|
"Capi y Pixel de Facebook",
|
||||||
|
"Creacion de Textos Comerciales",
|
||||||
|
"Capacitacion Basica de Tienda",
|
||||||
|
"Incluye tres revisiones del Proyecto",
|
||||||
|
"Dominio",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Creacion y Optimizacion de Redes Sociales",
|
||||||
|
descripcion: "Creacion y brandeo de perfiles en redes sociales",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2500,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "redes",
|
||||||
|
orden: 10,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Creacion de Redes Sociales (Facebook, Instagram)",
|
||||||
|
"Brandeo de las Redes y Optimizacion",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Creacion y Optimizacion de la Ficha de Google My Business",
|
||||||
|
descripcion: "Configuracion y optimizacion de perfil de Google",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2000,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "seo",
|
||||||
|
orden: 11,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Creacion y Configuracion de Perfil de Negocio de Google",
|
||||||
|
"Optimizacion de Perfil de Negocio de Google",
|
||||||
|
"Generacion de Mapas Fractales",
|
||||||
|
"Plantillas para Publicaciones en Perfil de Negocio",
|
||||||
|
"Reporte de resultados",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Configuracion y Puesta en Marcha del CRM Bucefalo",
|
||||||
|
descripcion: "Configuracion inicial de cuenta CRM Bucefalo",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 3900,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "crm",
|
||||||
|
orden: 12,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Configuracion de cuenta CRM Bucefalo",
|
||||||
|
"Curso de Capacitacion",
|
||||||
|
"Configuracion de Automatizaciones",
|
||||||
|
"Configuracion de Pipeline de Oportunidades",
|
||||||
|
"Configuracion de Campos Personalizados",
|
||||||
|
"Integracion de Calendario",
|
||||||
|
"Configuracion de Reportes",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Configuracion, Optimizacion y Puesta en Marcha de Google Ads y Meta Ads",
|
||||||
|
descripcion: "Setup completo de ambas plataformas publicitarias",
|
||||||
|
fase: 1,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 4000,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "ads",
|
||||||
|
orden: 13,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Configuracion y estructura de las Campanas de Google Ads",
|
||||||
|
"Configuracion de Conversiones",
|
||||||
|
"Configuracion de la Segmentacion de Publicos Personalizados",
|
||||||
|
"Configuracion de listas de terminos de busqueda negativos",
|
||||||
|
"Configuracion de listas de terminos busqueda",
|
||||||
|
"Configuracion de Anuncios publicitarios",
|
||||||
|
"Configuracion de Extensiones en los anuncios",
|
||||||
|
"Configuracion de Cuenta Facebook Ads e Instagram Ads",
|
||||||
|
"Configuracion de Pixeles de Conversion",
|
||||||
|
"Configuracion de Anuncios Publicitarios para Meta",
|
||||||
|
"Configuracion de Conversiones Personalizadas",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Tablero de Inspiracion",
|
||||||
|
descripcion: "Estrategia creativa para campanas publicitarias",
|
||||||
|
fase: 2,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2900,
|
||||||
|
tiempoEntrega: "6 - 10 dias",
|
||||||
|
categoria: "estrategia",
|
||||||
|
orden: 14,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Estrategia Creativa",
|
||||||
|
"Segmentacion de Mercado",
|
||||||
|
"Complexogramo",
|
||||||
|
"Analisis de Competencia",
|
||||||
|
"Analisis de los mejores Anuncios Locales, Nacionales e Internacionales",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Configuracion y Optimizacion Google Ads",
|
||||||
|
descripcion: "Configuracion y optimizacion mensual de Google Ads",
|
||||||
|
fase: 2,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 4900,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "ads",
|
||||||
|
orden: 15,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Revision de la Segmentacion de Publicos Personalizados",
|
||||||
|
"Revision de Listas de Terminos de Busqueda Negativos",
|
||||||
|
"Revision de Listas de Terminos Busqueda",
|
||||||
|
"Revision de Anuncios Publicitarios",
|
||||||
|
"Revision de Anuncios Graficos",
|
||||||
|
"Una Sesion de Acompanamiento para Google",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Manejo y Optimizacion de Meta Ads",
|
||||||
|
descripcion: "Manejo y optimizacion mensual de Meta Ads",
|
||||||
|
fase: 2,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 1500,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "ads",
|
||||||
|
orden: 16,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Revision de la Configuracion de Conversiones",
|
||||||
|
"Revision de la Segmentacion de Publicos Personalizados",
|
||||||
|
"Revision de Conversiones Personalizadas",
|
||||||
|
"Revision de Anuncios Publicitarios",
|
||||||
|
"Una Sesion de Acompanamiento para la Creacion de Portafolio Comercial / Facebook",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Manejo Basico de Redes Sociales",
|
||||||
|
descripcion: "Manejo mensual basico de redes sociales",
|
||||||
|
fase: 2,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 3000,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "redes",
|
||||||
|
orden: 17,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Realizacion de Parrilla, Diseno y Programacion",
|
||||||
|
"3 - 5 Post Mensuales",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Plan Mensual CRM Bucefalo",
|
||||||
|
descripcion: "Suscripcion mensual al CRM Bucefalo",
|
||||||
|
fase: 2,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 3900,
|
||||||
|
tiempoEntrega: "Mensual",
|
||||||
|
categoria: "crm",
|
||||||
|
orden: 18,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Uso completo del CRM segun plan contratado",
|
||||||
|
"Soporte tecnico",
|
||||||
|
"Actualizaciones del sistema",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Desarrollo de Plan de Contenido",
|
||||||
|
descripcion: "Plan estrategico de contenido",
|
||||||
|
fase: 3,
|
||||||
|
tipoPago: "unico",
|
||||||
|
precioBase: 2900,
|
||||||
|
tiempoEntrega: "6 - 10 dias",
|
||||||
|
categoria: "contenido",
|
||||||
|
orden: 19,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Analisis situacional de la marca",
|
||||||
|
"Objetivos de la marca",
|
||||||
|
"Analisis de Audiencia y Competencia",
|
||||||
|
"Estrategias de Contenido",
|
||||||
|
"Propuesta de Contenido",
|
||||||
|
"Plan de Acciones",
|
||||||
|
"Canales de Distribucion",
|
||||||
|
"Medicion y Metricas",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Generacion de Contenido",
|
||||||
|
descripcion: "Generacion mensual de contenido para redes",
|
||||||
|
fase: 3,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 8000,
|
||||||
|
tiempoEntrega: "Mensual",
|
||||||
|
categoria: "contenido",
|
||||||
|
orden: 20,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Manejo de Parrilla Mensual (Facebook, Instagram, Pinterest, YouTube, Tik Tok)",
|
||||||
|
"Publicaciones Estaticas para Redes Sociales",
|
||||||
|
"Post para Blog",
|
||||||
|
"Video YouTube largo, Generacion de Reels, Shorts",
|
||||||
|
"Escucha Social (analisis de opiniones de clientes)",
|
||||||
|
"Junta de Reporte mensual",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Posicionamiento SEO",
|
||||||
|
descripcion: "Posicionamiento SEO mensual",
|
||||||
|
fase: 3,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 2900,
|
||||||
|
tiempoEntrega: "4 - 10 dias",
|
||||||
|
categoria: "seo",
|
||||||
|
orden: 21,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Posicionamiento de KW (3 palabras clave)",
|
||||||
|
"Traqueo de las Palabras Clave",
|
||||||
|
"Creacion de Enlaces Externos al Sitio",
|
||||||
|
"Citaciones para el Sitio",
|
||||||
|
"Reporte de resultados (mensual)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Estrategia SEO Completa",
|
||||||
|
descripcion: "Estrategia SEO completa con contenido y backlinks",
|
||||||
|
fase: 3,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 2900,
|
||||||
|
tiempoEntrega: "Mensual",
|
||||||
|
categoria: "seo",
|
||||||
|
orden: 22,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Busqueda y definicion de KW a atacar",
|
||||||
|
"Definicion de tematica y KW por Mes (calendario semestral)",
|
||||||
|
"Monitoreo de posiciones en SERPS de KW",
|
||||||
|
"Busqueda de backlinks de la competencia",
|
||||||
|
"Gestion con medios y webs para el correcto enlazado",
|
||||||
|
"Indexacion a Backlinks y monitoreo",
|
||||||
|
"Generacion de contenido SEO para Post del blog o Landing",
|
||||||
|
"Generacion de Imagenes y video para ilustracion",
|
||||||
|
"Generacion de Url (post o Landing) para posicionamiento",
|
||||||
|
"Optimizacion SEO de todas las Url nuevas",
|
||||||
|
"Enlazado Interno de todo el sitio web",
|
||||||
|
"Optimizacion de usabilidad del sitio",
|
||||||
|
"Optimizacion de Copy",
|
||||||
|
"Optimizacion de EEAT",
|
||||||
|
"Optimizacion de enlazado interno",
|
||||||
|
"Optimizacion de prominencia Semantica",
|
||||||
|
"Reporte mensual",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nombre: "Plan de Acompanamiento Integral",
|
||||||
|
descripcion: "Servicio integral de acompanamiento estrategico y operativo durante un periodo minimo obligatorio de 9 meses (3 meses operando E3, 3 operando en conjunto y 3 meses operando en acompanamiento)",
|
||||||
|
fase: 0,
|
||||||
|
tipoPago: "mensual",
|
||||||
|
precioBase: 9000,
|
||||||
|
tiempoEntrega: "Minimo 9 meses",
|
||||||
|
categoria: "acompanamiento",
|
||||||
|
orden: 23,
|
||||||
|
entregablesDefault: [
|
||||||
|
"Manejo de Google Ads (Performance Max, Demand Gen, IA Max for Search)",
|
||||||
|
"Implementacion con first-party data y third-party data",
|
||||||
|
"Reportes mensuales con analisis de resultados Google Ads",
|
||||||
|
"Manejo de Meta Ads (Advantage+, campanas por objetivo, remarketing dinamico)",
|
||||||
|
"Segmentacion y automatizacion con IA",
|
||||||
|
"Reportes mensuales con analisis de resultados Meta Ads",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const servicio of servicios) {
|
||||||
|
const catNombre = catAlias[servicio.categoria] || "Desarrollo Personalizado";
|
||||||
|
await prisma.servicioCatalogo.upsert({
|
||||||
|
where: { id: servicio.nombre.toLowerCase().replace(/\s+/g, "-").substring(0, 30) + "-" + servicio.orden },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
nombre: servicio.nombre,
|
||||||
|
descripcion: servicio.descripcion,
|
||||||
|
fase: servicio.fase,
|
||||||
|
tipoPago: servicio.tipoPago,
|
||||||
|
precioBase: servicio.precioBase,
|
||||||
|
tiempoEntrega: servicio.tiempoEntrega,
|
||||||
|
entregablesDefault: servicio.entregablesDefault,
|
||||||
|
categoriaId: catMap[catNombre] || null,
|
||||||
|
variante: servicio.variante || null,
|
||||||
|
orden: servicio.orden,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allServicios = await prisma.servicioCatalogo.findMany();
|
||||||
|
|
||||||
|
// ── PAQUETE GENERAL ──
|
||||||
|
const paquete = await prisma.paquete.upsert({
|
||||||
|
where: { id: "paquete-general" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: "paquete-general",
|
||||||
|
nombre: "General",
|
||||||
|
descripcion: "Paquete completo de servicios de marketing digital",
|
||||||
|
activo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fasesGeneral = [
|
||||||
|
{ nombre: "Auditoria / Acompanamiento", orden: 0 },
|
||||||
|
{ nombre: "Setup e Infraestructura", orden: 1 },
|
||||||
|
{ nombre: "Publicidad y Manejo", orden: 2 },
|
||||||
|
{ nombre: "Contenido y SEO", orden: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const faseMap: Record<number, string> = {};
|
||||||
|
for (const fg of fasesGeneral) {
|
||||||
|
const faseId = `fase-general-${fg.orden}`;
|
||||||
|
const created = await prisma.fasePaquete.upsert({
|
||||||
|
where: { id: faseId },
|
||||||
|
update: { nombre: fg.nombre, orden: fg.orden },
|
||||||
|
create: {
|
||||||
|
id: faseId,
|
||||||
|
paqueteId: paquete.id,
|
||||||
|
nombre: fg.nombre,
|
||||||
|
orden: fg.orden,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
faseMap[fg.orden] = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLinks = await prisma.servicioPaquete.findMany();
|
||||||
|
if (existingLinks.length === 0) {
|
||||||
|
for (const s of allServicios) {
|
||||||
|
const fasePaqueteId = faseMap[s.fase];
|
||||||
|
if (fasePaqueteId) {
|
||||||
|
await prisma.servicioPaquete.create({
|
||||||
|
data: {
|
||||||
|
servicioCatalogoId: s.id,
|
||||||
|
fasePaqueteId,
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bonos = [
|
||||||
|
{ numero: 1, titulo: "Servicio Centinela Web", descripcion: "30 minutos mensuales en servicios Centinela (Sitio Web)" },
|
||||||
|
{ numero: 2, titulo: "Workshop Buyer Persona", descripcion: "Workshop Estrategico de Buyer Persona" },
|
||||||
|
{ numero: 3, titulo: "Workshop Propuesta de Valor", descripcion: "Workshop de Propuestas de Valor y Oferta Irresistible" },
|
||||||
|
{ numero: 4, titulo: "Membresia Premium", descripcion: "1 ano de Membresia Premium" },
|
||||||
|
{ numero: 5, titulo: "Mes Gratis Bucefalo", descripcion: "Un mes gratis de Bucefalo CRM, Marketing y Ventas" },
|
||||||
|
{ numero: 6, titulo: "Script de Ventas", descripcion: "Script de Ventas con mas de 100 complementos" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const bono of bonos) {
|
||||||
|
await prisma.bono.create({ data: bono });
|
||||||
|
}
|
||||||
|
|
||||||
|
const planes = [
|
||||||
|
{ meses: 3, tasa: 0.077, comision: 2.5, montoMinimo: 300, iva: 0.16 },
|
||||||
|
{ meses: 6, tasa: 0.107, comision: 2.5, montoMinimo: 600, iva: 0.16 },
|
||||||
|
{ meses: 9, tasa: 0.137, comision: 2.5, montoMinimo: 900, iva: 0.16 },
|
||||||
|
{ meses: 12, tasa: 0.167, comision: 2.5, montoMinimo: 1200, iva: 0.16 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const plan of planes) {
|
||||||
|
await prisma.financiamientoPlan.upsert({
|
||||||
|
where: { meses: plan.meses },
|
||||||
|
update: {},
|
||||||
|
create: plan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = [
|
||||||
|
{
|
||||||
|
clave: "razon_social",
|
||||||
|
valor: "URIEL JARETH ALVARADO ORTIZ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "rfc",
|
||||||
|
valor: "AAOU970201SU7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "domicilio_fiscal",
|
||||||
|
valor: "EMILIANO ZAPATA S/N, CUAMIO, CUITZEO, MICHOACAN DE OCAMPO CP 58855",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "cuenta_nacional",
|
||||||
|
valor: "012227015788120440",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "clabe_interbancaria",
|
||||||
|
valor: "012227015788120440",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "cuenta_internacional",
|
||||||
|
valor: "Beneficiario: URIEL JARETH ALVARADO ORTIZ\nCLABE: 012227015788120440\nBanco: BBVA",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "cuenta_internacional_swift",
|
||||||
|
valor: "Beneficiario: URIEL JARETH ALVARADO ORTIZ\nCodigo SWIFT / BIC: BCMRMXMMPYM\nCLABE: 012227015788120440\nBanco: BBVA Mexico",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "hora_centinela",
|
||||||
|
valor: "700",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "anualidad_hosting",
|
||||||
|
valor: "5000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "iva",
|
||||||
|
valor: "0.16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "terminos_condiciones",
|
||||||
|
valor: "- Esta cotizacion tiene una vigencia de 15 dias habiles.\n- Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.\n- El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.\n- Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.\n- Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.\n- Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "no_incluye",
|
||||||
|
valor: "- Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado. (Estos servicios son Extras, pueden ser realizados por E3 y se cotizan como adicionales mediante cotizacion).\n- Redaccion de entradas de Blog.\n- Integracion de Servicios de terceros ajenos a los cotizados.\n- Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads (En Caso de Requerir el Servicio se Aplicara uso de Hora Centinela).\n- Creacion de Redes Sociales (En Caso de Requerir el Servicio Incluira un Costo Adicional).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "notas_adicionales",
|
||||||
|
valor: "- El presente proyecto debera tener un responsable oficial, a quien se le compartiran los avances y con quien se mantendra la comunicacion.\n- La Hora Centinela tiene un precio de $700.00 MXN.\n- Nuestros servicios incluyen la entrega de archivos finales listos para su uso (JPG, PNG, PDF, etc.). Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia y no estan incluidos, salvo en proyectos de Branding/Logotipos.\n- SI EL PROYECTO SE PAUSA POR RAZONES AJENAS A URIEL JARETH CONSULTING O FALTA DE CONTINUIDAD, ESTO GENERARA COSTO EXTRA PARA RETOMAR EL PROYECTO (SE CONSIDERARA UN COSTO QUE PUEDE IR DEL 15% AL 30% CON BASE A LA COTIZACION INICIAL).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "color_primario",
|
||||||
|
valor: "#2563eb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "color_secundario",
|
||||||
|
valor: "#1e293b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
clave: "logo_base64",
|
||||||
|
valor: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
await prisma.configuracion.upsert({
|
||||||
|
where: { clave: config.clave },
|
||||||
|
update: { valor: config.valor },
|
||||||
|
create: config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed completed successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
Package,
|
||||||
|
LayoutGrid,
|
||||||
|
Settings,
|
||||||
|
GripVertical,
|
||||||
|
Save,
|
||||||
|
FileDown,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { formatCurrency, FASES as FASES_LABELS } from "@/lib/calculators";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface Servicio {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
fase: number;
|
||||||
|
tipoPago: string;
|
||||||
|
precioBase: number;
|
||||||
|
tiempoEntrega: string;
|
||||||
|
entregablesDefault: string[];
|
||||||
|
categoriaId: string | null;
|
||||||
|
categoriaNombre: string;
|
||||||
|
categoriaColor: string;
|
||||||
|
variante: string | null;
|
||||||
|
activo: boolean;
|
||||||
|
orden: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Categoria {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
color: string;
|
||||||
|
activo: boolean;
|
||||||
|
orden: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FasePaqueteUI {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
orden: number;
|
||||||
|
servicios: { id: string; nombre: string; precioBase: number; tipoPago: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaqueteUI {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
descripcion: string | null;
|
||||||
|
fases: FasePaqueteUI[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialServicios: Servicio[];
|
||||||
|
initialCategorias: Categoria[];
|
||||||
|
initialPaquetes: PaqueteUI[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "categorias" | "paquetes" | "servicios";
|
||||||
|
|
||||||
|
const FASES = FASES_LABELS;
|
||||||
|
|
||||||
|
export function CatalogoClient({ initialServicios, initialCategorias, initialPaquetes }: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>("servicios");
|
||||||
|
const [servicios, setServicios] = useState<Servicio[]>(initialServicios);
|
||||||
|
const [categorias, setCategorias] = useState<Categoria[]>(initialCategorias);
|
||||||
|
const [paquetes, setPaquetes] = useState<PaqueteUI[]>(initialPaquetes);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [sRes, cRes, pRes] = await Promise.all([
|
||||||
|
fetch("/api/catalogo"),
|
||||||
|
fetch("/api/categorias"),
|
||||||
|
fetch("/api/paquetes"),
|
||||||
|
]);
|
||||||
|
if (sRes.ok) setServicios(await sRes.json());
|
||||||
|
if (cRes.ok) setCategorias(await cRes.json());
|
||||||
|
if (pRes.ok) setPaquetes(await pRes.json());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch("/api/import/catalogo", { method: "POST", body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
let msg = `${data.creados} servicio(s) importado(s).`;
|
||||||
|
if (data.omitidos > 0) msg += ` ${data.omitidos} omitido(s).`;
|
||||||
|
if (data.errores?.length) msg += `\n\nErrores:\n${data.errores.join("\n")}`;
|
||||||
|
alert(msg);
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
alert(data.error || "Error al importar");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert("Error al importar CSV");
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCategoria = async (id: string) => {
|
||||||
|
const res = await fetch(`/api/categorias/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) setCategorias((p) => p.filter((c) => c.id !== id));
|
||||||
|
else { const e = await res.json(); alert(e.error); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategoriaActivo = async (cat: Categoria) => {
|
||||||
|
const res = await fetch(`/api/categorias/${cat.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...cat, activo: !cat.activo }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
setCategorias((p) => p.map((c) => (c.id === cat.id ? updated : c)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePaquete = async (id: string) => {
|
||||||
|
if (!confirm("Eliminar este paquete y todas sus fases?")) return;
|
||||||
|
const res = await fetch(`/api/paquetes/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) setPaquetes((p) => p.filter((pk) => pk.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreatePaquete = async () => {
|
||||||
|
const nombre = prompt("Nombre del paquete:");
|
||||||
|
if (!nombre) return;
|
||||||
|
const res = await fetch("/api/paquetes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nombre, fases: [] }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const created = await res.json();
|
||||||
|
setPaquetes((p) => [...p, { ...created, fases: [] }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFaseToPaquete = async (paqueteId: string) => {
|
||||||
|
const nombre = prompt("Nombre de la fase:");
|
||||||
|
if (!nombre) return;
|
||||||
|
const pk = paquetes.find((p) => p.id === paqueteId);
|
||||||
|
const orden = pk ? pk.fases.length : 0;
|
||||||
|
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "addFase", nombre, orden }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const fase = await res.json();
|
||||||
|
setPaquetes((p) =>
|
||||||
|
p.map((pk) =>
|
||||||
|
pk.id === paqueteId ? { ...pk, fases: [...pk.fases, { ...fase, servicios: [] }] } : pk
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFase = async (paqueteId: string, faseId: string) => {
|
||||||
|
if (!confirm("Eliminar esta fase?")) return;
|
||||||
|
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "deleteFase", faseId }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setPaquetes((p) =>
|
||||||
|
p.map((pk) =>
|
||||||
|
pk.id === paqueteId ? { ...pk, fases: pk.fases.filter((f) => f.id !== faseId) } : pk
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeServFromFase = async (paqueteId: string, faseId: string, servicioId: string) => {
|
||||||
|
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "removeServicio", servicioCatalogoId: servicioId, fasePaqueteId: faseId }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setPaquetes((p) =>
|
||||||
|
p.map((pk) =>
|
||||||
|
pk.id === paqueteId
|
||||||
|
? {
|
||||||
|
...pk,
|
||||||
|
fases: pk.fases.map((f) =>
|
||||||
|
f.id === faseId
|
||||||
|
? { ...f, servicios: f.servicios.filter((s) => s.id !== servicioId) }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: pk
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addServToFase = async (paqueteId: string, faseId: string, servicioId: string) => {
|
||||||
|
const res = await fetch(`/api/paquetes/${paqueteId}/manage`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "addServicio", servicioCatalogoId: servicioId, fasePaqueteId: faseId }),
|
||||||
|
});
|
||||||
|
if (res.ok) await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [addModal, setAddModal] = useState<{ paqueteId: string; faseId: string } | null>(null);
|
||||||
|
const [servicioModal, setServicioModal] = useState<Servicio | null>(null);
|
||||||
|
const [showNewServicio, setShowNewServicio] = useState(false);
|
||||||
|
const [expandedPk, setExpandedPk] = useState<Set<string>>(new Set(paquetes.map((p) => p.id)));
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ key: "servicios", label: "Servicios", icon: <LayoutGrid className="w-4 h-4" /> },
|
||||||
|
{ key: "categorias", label: "Categorias", icon: <Settings className="w-4 h-4" /> },
|
||||||
|
{ key: "paquetes", label: "Paquetes", icon: <Package className="w-4 h-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Catalogo de Servicios</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="/api/export/catalogo/plantilla"
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4" />
|
||||||
|
Plantilla CSV
|
||||||
|
</a>
|
||||||
|
<label className={`flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors cursor-pointer ${importing ? "opacity-50 pointer-events-none" : ""}`}>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
{importing ? "Importando..." : "Importar CSV"}
|
||||||
|
<input type="file" accept=".csv" onChange={handleImport} className="hidden" />
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href="/api/export/catalogo"
|
||||||
|
download
|
||||||
|
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<FileDown className="w-4 h-4" />
|
||||||
|
Exportar CSV
|
||||||
|
</a>
|
||||||
|
<button onClick={refresh} disabled={loading} className="text-xs text-muted hover:text-dark">
|
||||||
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Actualizar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 mb-6 bg-gray-100 p-1 rounded-lg w-fit">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
tab === t.key ? "bg-white shadow text-dark" : "text-muted hover:text-dark"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.icon}{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── TAB: SERVICIOS (por categoria) ── */}
|
||||||
|
{tab === "servicios" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm text-muted">{servicios.filter((s) => s.activo).length} servicios activos</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewServicio(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Nuevo Servicio
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{categorias.filter((c) => c.activo).map((cat) => {
|
||||||
|
const catServs = servicios.filter((s) => s.categoriaNombre === cat.nombre);
|
||||||
|
if (!catServs.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||||
|
<h2 className="font-semibold text-lg">{cat.nombre}</h2>
|
||||||
|
<span className="text-xs text-muted">({catServs.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-gray-50">
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted">Servicio</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted">Tipo</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-muted">Tiempo</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-muted">Precio</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-muted">Entregables</th>
|
||||||
|
<th className="text-center px-4 py-3 font-medium text-muted w-24">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{catServs.map((s) => (
|
||||||
|
<tr key={s.id} className={clsx("border-b border-border hover:bg-gray-50", !s.activo && "opacity-50")}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="font-medium">{s.nombre}</div>
|
||||||
|
{s.descripcion && <div className="text-xs text-muted mt-0.5">{s.descripcion}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={clsx("inline-block px-2 py-0.5 rounded text-xs font-medium", s.tipoPago === "unico" ? "bg-blue-100 text-blue-700" : "bg-green-100 text-green-700")}>
|
||||||
|
{s.tipoPago === "unico" ? "Unico" : "Mensual"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted text-xs">{s.tiempoEntrega}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{formatCurrency(s.precioBase)}</td>
|
||||||
|
<td className="px-4 py-3 text-center text-muted">{s.entregablesDefault.length}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button onClick={() => setServicioModal(s)} className="p-1.5 text-muted hover:text-primary rounded" title="Editar">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
if (!confirm(`Eliminar "${s.nombre}"?`)) return;
|
||||||
|
const res = await fetch(`/api/catalogo/${s.id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) { const d = await res.json(); if (d.archived) { setServicios((p) => p.map((x) => x.id === s.id ? { ...x, activo: false } : x)); } else { setServicios((p) => p.filter((x) => x.id !== s.id)); } }
|
||||||
|
}} className="p-1.5 text-muted hover:text-red-500 rounded" title="Eliminar">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── TAB: CATEGORIAS ── */}
|
||||||
|
{tab === "categorias" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{categorias.map((cat) => (
|
||||||
|
<div key={cat.id} className="bg-card-bg rounded-xl border border-border p-4 flex items-center gap-4">
|
||||||
|
<div className="w-5 h-5 rounded-full shrink-0" style={{ backgroundColor: cat.color }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium">{cat.nombre}</div>
|
||||||
|
{cat.descripcion && <div className="text-xs text-muted">{cat.descripcion}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted">
|
||||||
|
{servicios.filter((s) => s.categoriaNombre === cat.nombre).length} servicios
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCategoriaActivo(cat)}
|
||||||
|
className={clsx("px-3 py-1 rounded text-xs font-medium", cat.activo ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")}
|
||||||
|
>
|
||||||
|
{cat.activo ? "Activa" : "Inactiva"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteCategoria(cat.id)} className="p-1.5 text-muted hover:text-red-500 rounded">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<CreateCategoriaInline onCreated={refresh} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── TAB: PAQUETES ── */}
|
||||||
|
{tab === "paquetes" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={handleCreatePaquete} className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark">
|
||||||
|
<Plus className="w-4 h-4" /> Nuevo Paquete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paquetes.map((pk) => {
|
||||||
|
const isOpen = expandedPk.has(pk.id);
|
||||||
|
return (
|
||||||
|
<div key={pk.id} className="bg-card-bg rounded-xl border border-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedPk((prev) => { const n = new Set(prev); n.has(pk.id) ? n.delete(pk.id) : n.add(pk.id); return n; })}
|
||||||
|
className="w-full flex items-center gap-3 px-5 py-4 text-left hover:bg-gray-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{isOpen ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-lg">{pk.nombre}</div>
|
||||||
|
{pk.descripcion && <div className="text-xs text-muted">{pk.descripcion}</div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted">{pk.fases.length} fases</span>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); deletePaquete(pk.id); }} className="p-1.5 text-muted hover:text-red-500 rounded">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-5 pb-4 space-y-4">
|
||||||
|
{pk.fases.map((fase) => (
|
||||||
|
<div key={fase.id} className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50">
|
||||||
|
<span className="font-medium text-sm">{fase.nombre}</span>
|
||||||
|
<span className="text-xs text-muted">({fase.servicios.length} servicios)</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button onClick={() => setAddModal({ paqueteId: pk.id, faseId: fase.id })} className="text-xs text-primary hover:underline mr-3">
|
||||||
|
+ Agregar servicio
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteFase(pk.id, fase.id)} className="text-xs text-red-500 hover:underline">
|
||||||
|
Eliminar fase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fase.servicios.length > 0 ? (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{fase.servicios.map((s) => (
|
||||||
|
<tr key={s.id} className="border-t border-border">
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="font-medium">{s.nombre}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={clsx("text-xs px-2 py-0.5 rounded", s.tipoPago === "unico" ? "bg-blue-100 text-blue-700" : "bg-green-100 text-green-700")}>
|
||||||
|
{s.tipoPago === "unico" ? "Unico" : "Mensual"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium text-sm">{formatCurrency(s.precioBase)}</td>
|
||||||
|
<td className="px-4 py-2 w-10">
|
||||||
|
<button onClick={() => removeServFromFase(pk.id, fase.id, s.id)} className="text-muted hover:text-red-500">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="px-4 py-3 text-xs text-muted">Sin servicios asignados</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={() => addFaseToPaquete(pk.id)} className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Agregar fase
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── MODAL: Add service to phase ── */}
|
||||||
|
{addModal && (
|
||||||
|
<AddServicioModal
|
||||||
|
servicios={servicios.filter((s) => s.activo)}
|
||||||
|
onAdd={(servicioId) => {
|
||||||
|
addServToFase(addModal.paqueteId, addModal.faseId, servicioId);
|
||||||
|
setAddModal(null);
|
||||||
|
}}
|
||||||
|
onClose={() => setAddModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(servicioModal || showNewServicio) && (
|
||||||
|
<ServicioFormModal
|
||||||
|
servicio={servicioModal}
|
||||||
|
categorias={categorias}
|
||||||
|
onSave={async (data) => {
|
||||||
|
if (servicioModal) {
|
||||||
|
const res = await fetch(`/api/catalogo/${servicioModal.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
setServicios((p) => p.map((s) => (s.id === servicioModal.id ? { ...s, ...updated, categoriaNombre: categorias.find((c) => c.id === data.categoriaId)?.nombre || s.categoriaNombre, categoriaColor: categorias.find((c) => c.id === data.categoriaId)?.color || s.categoriaColor } : s)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await fetch("/api/catalogo", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const created = await res.json();
|
||||||
|
const cat = categorias.find((c) => c.id === data.categoriaId);
|
||||||
|
setServicios((p) => [...p, { ...created, categoriaNombre: cat?.nombre || "Sin categoria", categoriaColor: cat?.color || "#6b7280" }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setServicioModal(null);
|
||||||
|
setShowNewServicio(false);
|
||||||
|
}}
|
||||||
|
onClose={() => { setServicioModal(null); setShowNewServicio(false); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateCategoriaInline({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const [nombre, setNombre] = useState("");
|
||||||
|
const [color, setColor] = useState("#6b7280");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!nombre.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/categorias", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nombre: nombre.trim(), color }),
|
||||||
|
});
|
||||||
|
if (res.ok) { setNombre(""); onCreated(); }
|
||||||
|
else { const e = await res.json(); alert(e.error); }
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 bg-card-bg rounded-xl border border-dashed border-border p-4">
|
||||||
|
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="w-8 h-8 rounded cursor-pointer p-0 border-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nombre}
|
||||||
|
onChange={(e) => setNombre(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||||
|
placeholder="Nueva categoria..."
|
||||||
|
className="flex-1 px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button onClick={handleSave} disabled={saving || !nombre.trim()} className="flex items-center gap-2 px-3 py-2 bg-primary text-white rounded-lg text-sm disabled:opacity-50">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||||
|
Crear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddServicioModal({
|
||||||
|
servicios,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
servicios: Servicio[];
|
||||||
|
onAdd: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const filtered = servicios.filter(
|
||||||
|
(s) =>
|
||||||
|
s.nombre.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.categoriaNombre.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[70vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">Agregar servicio a la fase</h3>
|
||||||
|
<button onClick={onClose}><X className="w-5 h-5 text-muted" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-b border-border">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar por nombre o categoria..."
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => onAdd(s.id)}
|
||||||
|
className="w-full text-left px-5 py-3 hover:bg-gray-50 border-b border-border/50 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: s.categoriaColor }} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-sm truncate">{s.nombre}</div>
|
||||||
|
<div className="text-xs text-muted">{s.categoriaNombre} · {s.tipoPago === "unico" ? "Unico" : "Mensual"}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{formatCurrency(s.precioBase)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <p className="text-center text-muted text-sm py-8">Sin resultados</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServicioFormModal({
|
||||||
|
servicio,
|
||||||
|
categorias,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
servicio: Servicio | null;
|
||||||
|
categorias: Categoria[];
|
||||||
|
onSave: (data: Record<string, unknown>) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
nombre: servicio?.nombre || "",
|
||||||
|
descripcion: servicio?.descripcion || "",
|
||||||
|
categoriaId: servicio?.categoriaId || "",
|
||||||
|
tipoPago: servicio?.tipoPago || "unico",
|
||||||
|
precioBase: servicio?.precioBase ?? 0,
|
||||||
|
tiempoEntrega: servicio?.tiempoEntrega || "4 - 10 dias",
|
||||||
|
entregablesDefault: servicio?.entregablesDefault?.join("\n") || "",
|
||||||
|
variante: servicio?.variante || "",
|
||||||
|
fase: servicio?.fase ?? 1,
|
||||||
|
orden: servicio?.orden ?? 0,
|
||||||
|
activo: servicio?.activo ?? true,
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.nombre.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const entregables = form.entregablesDefault
|
||||||
|
.split("\n")
|
||||||
|
.map((e) => e.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const cat = categorias.find((c) => c.id === form.categoriaId);
|
||||||
|
await onSave({
|
||||||
|
nombre: form.nombre.trim(),
|
||||||
|
descripcion: form.descripcion.trim() || null,
|
||||||
|
categoriaId: form.categoriaId || null,
|
||||||
|
tipoPago: form.tipoPago,
|
||||||
|
precioBase: Number(form.precioBase),
|
||||||
|
tiempoEntrega: form.tiempoEntrega,
|
||||||
|
entregablesDefault: entregables,
|
||||||
|
variante: form.variante.trim() || null,
|
||||||
|
fase: form.fase,
|
||||||
|
orden: form.orden,
|
||||||
|
activo: form.activo,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (key: string, value: unknown) => setForm((p) => ({ ...p, [key]: value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="px-5 py-4 border-b border-border flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold">{servicio ? "Editar Servicio" : "Nuevo Servicio"}</h3>
|
||||||
|
<button onClick={onClose}><X className="w-5 h-5 text-muted" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto flex-1 p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Nombre *</label>
|
||||||
|
<input type="text" value={form.nombre} onChange={(e) => update("nombre", e.target.value)} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Descripcion</label>
|
||||||
|
<textarea value={form.descripcion} onChange={(e) => update("descripcion", e.target.value)} rows={2} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-none" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Categoria</label>
|
||||||
|
<select value={form.categoriaId} onChange={(e) => update("categoriaId", e.target.value)} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white">
|
||||||
|
<option value="">Sin categoria</option>
|
||||||
|
{categorias.map((c) => <option key={c.id} value={c.id}>{c.nombre}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tipo de Pago</label>
|
||||||
|
<select value={form.tipoPago} onChange={(e) => update("tipoPago", e.target.value)} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white">
|
||||||
|
<option value="unico">Unico</option>
|
||||||
|
<option value="mensual">Mensual</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Precio Base</label>
|
||||||
|
<input type="number" value={form.precioBase} onChange={(e) => update("precioBase", Number(e.target.value))} min={0} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Fase</label>
|
||||||
|
<select value={form.fase} onChange={(e) => update("fase", Number(e.target.value))} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white">
|
||||||
|
{Object.entries(FASES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Tiempo Entrega</label>
|
||||||
|
<input type="text" value={form.tiempoEntrega} onChange={(e) => update("tiempoEntrega", e.target.value)} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Entregables (uno por linea)</label>
|
||||||
|
<textarea value={form.entregablesDefault} onChange={(e) => update("entregablesDefault", e.target.value)} rows={4} placeholder="Entregable 1 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 className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Variante</label>
|
||||||
|
<input type="text" value={form.variante} onChange={(e) => update("variante", e.target.value)} placeholder="Ej: WooCommerce, Shopify..." className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Orden</label>
|
||||||
|
<input type="number" value={form.orden} onChange={(e) => update("orden", Number(e.target.value))} min={0} className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.activo} onChange={(e) => update("activo", e.target.checked)} className="rounded border-border" />
|
||||||
|
Activo
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 border-t border-border flex justify-end gap-3">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 border border-border rounded-lg text-sm hover:bg-gray-50">Cancelar</button>
|
||||||
|
<button onClick={handleSave} disabled={saving || !form.nombre.trim()} className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium disabled:opacity-50 hover:bg-primary-dark">
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{servicio ? "Guardar Cambios" : "Crear Servicio"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { CatalogoClient } from "./CatalogoClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function CatalogoPage() {
|
||||||
|
const [servicios, categorias, paquetes] = await Promise.all([
|
||||||
|
prisma.servicioCatalogo.findMany({
|
||||||
|
orderBy: [{ fase: "asc" }, { orden: "asc" }],
|
||||||
|
include: { categoriaRel: true, paquetes: { include: { fasePaquete: true } } },
|
||||||
|
}),
|
||||||
|
prisma.categoria.findMany({ orderBy: { orden: "asc" } }),
|
||||||
|
prisma.paquete.findMany({
|
||||||
|
where: { activo: true },
|
||||||
|
include: {
|
||||||
|
fases: {
|
||||||
|
orderBy: { orden: "asc" },
|
||||||
|
include: {
|
||||||
|
servicios: { include: { servicio: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CatalogoClient
|
||||||
|
initialServicios={servicios.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
nombre: s.nombre,
|
||||||
|
descripcion: s.descripcion,
|
||||||
|
fase: s.fase,
|
||||||
|
tipoPago: s.tipoPago,
|
||||||
|
precioBase: s.precioBase,
|
||||||
|
tiempoEntrega: s.tiempoEntrega,
|
||||||
|
entregablesDefault: Array.isArray(s.entregablesDefault) ? (s.entregablesDefault as string[]) : [],
|
||||||
|
categoriaId: s.categoriaId,
|
||||||
|
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
|
||||||
|
categoriaColor: s.categoriaRel?.color || "#6b7280",
|
||||||
|
variante: s.variante,
|
||||||
|
activo: s.activo,
|
||||||
|
orden: s.orden,
|
||||||
|
}))}
|
||||||
|
initialCategorias={categorias}
|
||||||
|
initialPaquetes={paquetes.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
nombre: p.nombre,
|
||||||
|
descripcion: p.descripcion,
|
||||||
|
fases: p.fases.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
nombre: f.nombre,
|
||||||
|
orden: f.orden,
|
||||||
|
servicios: f.servicios.map((sp) => sp.servicio),
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { NuevaCotizacionClienteButton } from "./NuevaCotizacionClienteButton";
|
||||||
|
|
||||||
|
interface Cliente {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
empresa: string | null;
|
||||||
|
email: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
cotizaciones: { id: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientesList({ clientes }: { clientes: Cliente[] }) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filtered = clientes.filter((c) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
c.nombre.toLowerCase().includes(q) ||
|
||||||
|
(c.empresa || "").toLowerCase().includes(q) ||
|
||||||
|
(c.email || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar por nombre, empresa, email..."
|
||||||
|
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-center text-muted py-8">
|
||||||
|
{search ? "Sin resultados para esa busqueda" : "No hay clientes"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-gray-50">
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Nombre
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Empresa
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Telefono
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-5 py-3 font-medium text-muted">
|
||||||
|
Cotizaciones
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-5 py-3 font-medium text-muted w-16" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<tr
|
||||||
|
key={c.id}
|
||||||
|
className="border-b border-border hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3 font-medium">{c.nombre}</td>
|
||||||
|
<td className="px-5 py-3">{c.empresa || "-"}</td>
|
||||||
|
<td className="px-5 py-3 text-muted">{c.email || "-"}</td>
|
||||||
|
<td className="px-5 py-3 text-muted">{c.telefono || "-"}</td>
|
||||||
|
<td className="px-5 py-3 text-center">
|
||||||
|
<span className="inline-block px-2 py-0.5 bg-primary-light text-primary rounded text-xs font-medium">
|
||||||
|
{c.cotizaciones.length}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-center">
|
||||||
|
<NuevaCotizacionClienteButton
|
||||||
|
clienteNombre={c.nombre}
|
||||||
|
clienteEmpresa={c.empresa || ""}
|
||||||
|
clienteEmail={c.email || ""}
|
||||||
|
clienteTelefono={c.telefono || ""}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useCotizacionStore } from "@/lib/store";
|
||||||
|
|
||||||
|
export function NuevaCotizacionClienteButton({
|
||||||
|
clienteNombre,
|
||||||
|
clienteEmpresa,
|
||||||
|
clienteEmail,
|
||||||
|
clienteTelefono,
|
||||||
|
}: {
|
||||||
|
clienteNombre: string;
|
||||||
|
clienteEmpresa: string;
|
||||||
|
clienteEmail: string;
|
||||||
|
clienteTelefono: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useCotizacionStore();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
store.resetDraft();
|
||||||
|
store.setField("clienteNombre", clienteNombre);
|
||||||
|
store.setField("clienteEmpresa", clienteEmpresa);
|
||||||
|
store.setField("clienteEmail", clienteEmail);
|
||||||
|
store.setField("clienteTelefono", clienteTelefono);
|
||||||
|
router.push("/cotizaciones/nueva");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="p-1.5 text-muted hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||||
|
title="Nueva cotizacion"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ClientesPage() {
|
||||||
|
const clientes = await prisma.cliente.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { cotizaciones: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Clientes</h1>
|
||||||
|
<p className="text-muted text-sm mt-1">
|
||||||
|
{clientes.length} clientes registrados
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clientes.length === 0 ? (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border p-12 text-center">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">No hay clientes</h2>
|
||||||
|
<p className="text-muted mb-4">
|
||||||
|
Los clientes se crean automaticamente al generar cotizaciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ClientesListWrapper clientes={clientes} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { ClientesList } from "./ClientesList";
|
||||||
|
|
||||||
|
function ClientesListWrapper({
|
||||||
|
clientes,
|
||||||
|
}: {
|
||||||
|
clientes: {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
empresa: string | null;
|
||||||
|
email: string | null;
|
||||||
|
telefono: string | null;
|
||||||
|
cotizaciones: { id: string }[];
|
||||||
|
}[];
|
||||||
|
}) {
|
||||||
|
return <ClientesList clientes={clientes} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { Save, Upload, Trash2, Loader2, Image } from "lucide-react";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
title: "Branding",
|
||||||
|
icon: "🎨",
|
||||||
|
fields: [
|
||||||
|
{ key: "color_primario", label: "Color Primario", type: "color" },
|
||||||
|
{ key: "color_secundario", label: "Color Secundario", type: "color" },
|
||||||
|
],
|
||||||
|
hasLogo: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Datos Fiscales",
|
||||||
|
icon: "📋",
|
||||||
|
fields: [
|
||||||
|
{ key: "razon_social", label: "Razon Social", type: "text" },
|
||||||
|
{ key: "rfc", label: "RFC", type: "text" },
|
||||||
|
{ key: "domicilio_fiscal", label: "Domicilio Fiscal", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Datos Bancarios Nacionales",
|
||||||
|
icon: "🏦",
|
||||||
|
fields: [
|
||||||
|
{ key: "cuenta_nacional", label: "Cuenta", type: "text" },
|
||||||
|
{ key: "clabe_interbancaria", label: "CLABE Interbancaria", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Datos Bancarios Internacionales",
|
||||||
|
icon: "🌎",
|
||||||
|
fields: [
|
||||||
|
{ key: "cuenta_internacional", label: "Transferencia Internacional (ABA)", type: "textarea" },
|
||||||
|
{ key: "cuenta_internacional_swift", label: "Transferencia Internacional (SWIFT)", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Configuracion del Sistema",
|
||||||
|
icon: "⚙️",
|
||||||
|
fields: [
|
||||||
|
{ key: "hora_centinela", label: "Precio Hora Centinela (MXN)", type: "text" },
|
||||||
|
{ key: "anualidad_hosting", label: "Anualidad Hosting (MXN)", type: "text" },
|
||||||
|
{ key: "iva", label: "IVA (decimal)", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Textos Legales",
|
||||||
|
icon: "📜",
|
||||||
|
fields: [
|
||||||
|
{ key: "terminos_condiciones", label: "Terminos y Condiciones", type: "textarea" },
|
||||||
|
{ key: "no_incluye", label: "Que No Incluye", type: "textarea" },
|
||||||
|
{ key: "notas_adicionales", label: "Notas Adicionales", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ConfiguracionClient({ initial }: { initial: Record<string, string> }) {
|
||||||
|
const [config, setConfig] = useState<Record<string, string>>(initial);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: string) => {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setSaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 500 * 1024) {
|
||||||
|
alert("El logo debe ser menor a 500KB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string;
|
||||||
|
const base64 = result.split(",")[1];
|
||||||
|
handleChange("logo_base64", `${file.type}:${base64}`);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLogo = () => {
|
||||||
|
handleChange("logo_base64", "");
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/configuracion", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} else {
|
||||||
|
alert("Error al guardar");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert("Error al guardar");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Configuracion</h1>
|
||||||
|
<p className="text-muted text-sm mt-1">Personaliza el branding y datos de tu empresa</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark disabled:opacity-50 transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{saving ? "Guardando..." : saved ? "Guardado!" : "Guardar Cambios"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<div key={section.title} className="bg-card-bg rounded-xl border border-border">
|
||||||
|
<div className="px-5 py-4 border-b border-border flex items-center gap-2">
|
||||||
|
<span>{section.icon}</span>
|
||||||
|
<h2 className="font-semibold">{section.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{section.hasLogo && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-muted mb-2">Logo de la Empresa</label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-24 h-24 border-2 border-dashed border-border rounded-lg flex items-center justify-center bg-gray-50 shrink-0 overflow-hidden">
|
||||||
|
{config.logo_base64 ? (
|
||||||
|
<img
|
||||||
|
src={config.logo_base64.includes(":") ? `data:${config.logo_base64.replace(":", ";base64,")}` : `data:image/png;base64,${config.logo_base64}`}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-full h-full object-contain p-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image className="w-8 h-8 text-muted" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Subir Logo
|
||||||
|
</button>
|
||||||
|
{config.logo_base64 && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveLogo}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">PNG, JPG, SVG o WebP. Maximo 500KB.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{section.fields.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-sm font-medium text-muted mb-1">{field.label}</label>
|
||||||
|
{field.type === "color" ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={config[field.key] || "#2563eb"}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
className="w-10 h-10 rounded-lg border border-border cursor-pointer p-0.5"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config[field.key] || ""}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
className="w-32 px-3 py-2 border border-border rounded-lg text-sm font-mono"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-full border border-border"
|
||||||
|
style={{ backgroundColor: config[field.key] || "#2563eb" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : field.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
value={config[field.key] || ""}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={config[field.key] || ""}
|
||||||
|
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ConfiguracionClient } from "./ConfiguracionClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ConfiguracionPage() {
|
||||||
|
const configs = await prisma.configuracion.findMany();
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const c of configs) map[c.clave] = c.valor;
|
||||||
|
|
||||||
|
return <ConfiguracionClient initial={map} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Pencil, Search } from "lucide-react";
|
||||||
|
import { formatDate, formatCurrency } from "@/lib/calculators";
|
||||||
|
import { EstadoBadge } from "@/components/EstadoBadge";
|
||||||
|
import { ListDeleteButton } from "./ListDeleteButton";
|
||||||
|
|
||||||
|
interface Cotizacion {
|
||||||
|
id: string;
|
||||||
|
numero: string;
|
||||||
|
estado: string;
|
||||||
|
fecha: Date;
|
||||||
|
cliente: { nombre: string; empresa: string | null };
|
||||||
|
asesor: { name: string };
|
||||||
|
servicios: { tipoPago: string; seleccionado: boolean; precio: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CotizacionesList({
|
||||||
|
cotizaciones,
|
||||||
|
}: {
|
||||||
|
cotizaciones: Cotizacion[];
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filtered = cotizaciones.filter((cot) => {
|
||||||
|
if (!search) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
cot.numero.toLowerCase().includes(q) ||
|
||||||
|
cot.cliente.nombre.toLowerCase().includes(q) ||
|
||||||
|
(cot.cliente.empresa || "").toLowerCase().includes(q) ||
|
||||||
|
cot.asesor.name.toLowerCase().includes(q) ||
|
||||||
|
cot.estado.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Buscar por numero, cliente, asesor..."
|
||||||
|
className="w-full pl-10 pr-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-center text-muted py-8">
|
||||||
|
{search ? "Sin resultados para esa busqueda" : "No hay cotizaciones"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-gray-50">
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
No. Cotizacion
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Cliente
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Asesor
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-5 py-3 font-medium text-muted">
|
||||||
|
Total Unico
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-5 py-3 font-medium text-muted">
|
||||||
|
Total Mensual
|
||||||
|
</th>
|
||||||
|
<th className="text-center px-5 py-3 font-medium text-muted">
|
||||||
|
Acciones
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((cot) => {
|
||||||
|
const totalUnico = cot.servicios
|
||||||
|
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
|
||||||
|
.reduce((sum, s) => sum + s.precio, 0);
|
||||||
|
const totalMensual = cot.servicios
|
||||||
|
.filter((s) => s.tipoPago === "mensual" && s.seleccionado)
|
||||||
|
.reduce((sum, s) => sum + s.precio, 0);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={cot.id}
|
||||||
|
className="border-b border-border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Link
|
||||||
|
href={`/cotizaciones/${cot.id}`}
|
||||||
|
className="text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{cot.numero}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div>
|
||||||
|
{cot.cliente.empresa || cot.cliente.nombre}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{cot.cliente.nombre}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-muted">
|
||||||
|
{cot.asesor.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-muted">
|
||||||
|
{formatDate(cot.fecha)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<EstadoBadge estado={cot.estado} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right font-medium">
|
||||||
|
{totalUnico > 0 ? formatCurrency(totalUnico) : "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right font-medium">
|
||||||
|
{totalMensual > 0
|
||||||
|
? formatCurrency(totalMensual)
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/cotizaciones/${cot.id}/editar`}
|
||||||
|
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
<ListDeleteButton id={cot.id} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function ListDeleteButton({ id }: { id: string }) {
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm("Eliminar esta cotizacion?")) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cotizaciones/${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) router.refresh();
|
||||||
|
else alert("Error al eliminar");
|
||||||
|
} catch {
|
||||||
|
alert("Error al eliminar");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "..." : "Eliminar"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const OPCIONES = [
|
||||||
|
{ value: "borrador", label: "Borrador" },
|
||||||
|
{ value: "enviada", label: "Enviada" },
|
||||||
|
{ value: "aprobada", label: "Aprobada" },
|
||||||
|
{ value: "rechazada", label: "Rechazada" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CambiarEstadoButtons({ cotizacionId, estado }: { cotizacionId: string; estado: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = async (nuevoEstado: string) => {
|
||||||
|
if (nuevoEstado === estado) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cotizaciones/${cotizacionId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ estado: nuevoEstado }),
|
||||||
|
});
|
||||||
|
if (res.ok) router.refresh();
|
||||||
|
else alert("Error al cambiar estado");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={estado}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-xs font-medium px-2 py-1 rounded-full border border-border bg-white focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{OPCIONES.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function DeleteCotizacionButton({
|
||||||
|
cotizacionId,
|
||||||
|
cotizacionNumero,
|
||||||
|
}: {
|
||||||
|
cotizacionId: string;
|
||||||
|
cotizacionNumero: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cotizaciones/${cotizacionId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
router.push("/cotizaciones");
|
||||||
|
} else {
|
||||||
|
alert("Error al eliminar");
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert("Error al eliminar");
|
||||||
|
setConfirming(false);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (confirming) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-red-600 font-medium">
|
||||||
|
Eliminar {cotizacionNumero}?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-3 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "Eliminando..." : "Si, eliminar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 border border-red-200 rounded-lg text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { formatCurrency, IVA_RATE } from "@/lib/calculators";
|
||||||
|
|
||||||
|
interface ServicioItem {
|
||||||
|
id: string;
|
||||||
|
nombre: string;
|
||||||
|
precio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreciosEditablesProps {
|
||||||
|
cotizacionId: string;
|
||||||
|
serviciosUnicos: ServicioItem[];
|
||||||
|
serviciosMensuales: ServicioItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
"w-28 px-2 py-1 border border-border rounded text-sm text-right focus:outline-none focus:ring-1 focus:ring-primary";
|
||||||
|
|
||||||
|
export function PreciosEditables({
|
||||||
|
cotizacionId,
|
||||||
|
serviciosUnicos,
|
||||||
|
serviciosMensuales,
|
||||||
|
}: PreciosEditablesProps) {
|
||||||
|
const [unicos, setUnicos] = useState(serviciosUnicos);
|
||||||
|
const [mensuales, setMensuales] = useState(serviciosMensuales);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const totalUnico = unicos.reduce((s, x) => s + x.precio, 0);
|
||||||
|
const totalMensual = mensuales.reduce((s, x) => s + x.precio, 0);
|
||||||
|
|
||||||
|
const handlePrecioChange = async (
|
||||||
|
servicioId: string,
|
||||||
|
nuevoPrecio: number,
|
||||||
|
tipo: "unico" | "mensual"
|
||||||
|
) => {
|
||||||
|
const lista = tipo === "unico" ? unicos : mensuales;
|
||||||
|
const setter = tipo === "unico" ? setUnicos : setMensuales;
|
||||||
|
const prev = lista;
|
||||||
|
|
||||||
|
setter(lista.map((s) => (s.id === servicioId ? { ...s, precio: nuevoPrecio } : s)));
|
||||||
|
|
||||||
|
setSaving(servicioId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cotizaciones/${cotizacionId}/precio`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ servicioId, precio: nuevoPrecio }),
|
||||||
|
});
|
||||||
|
if (!res.ok) setter(prev);
|
||||||
|
} catch {
|
||||||
|
setter(prev);
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 className="font-semibold">Pago Unico</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{unicos.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">Sin servicios</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{unicos.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
|
||||||
|
<span className="flex-1 truncate">{s.nombre}</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<span className="text-muted text-xs">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={s.precio}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "unico")
|
||||||
|
}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
{saving === s.id && (
|
||||||
|
<span className="text-xs text-muted">...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-border mt-3 pt-3 space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>{formatCurrency(totalUnico)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-muted">
|
||||||
|
<span>IVA (16%)</span>
|
||||||
|
<span>{formatCurrency(totalUnico * IVA_RATE)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm font-bold pt-2 border-t border-border">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{formatCurrency(totalUnico * (1 + IVA_RATE))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h3 className="font-semibold">Pago Mensual</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{mensuales.length === 0 ? (
|
||||||
|
<p className="text-muted text-sm">Sin servicios</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mensuales.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between text-sm gap-2">
|
||||||
|
<span className="flex-1 truncate">{s.nombre}</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<span className="text-muted text-xs">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={s.precio}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "mensual")
|
||||||
|
}
|
||||||
|
className={INPUT_CLS}
|
||||||
|
/>
|
||||||
|
{saving === s.id && (
|
||||||
|
<span className="text-xs text-muted">...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-border mt-3 pt-3 space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>{formatCurrency(totalMensual)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-muted">
|
||||||
|
<span>IVA (16%)</span>
|
||||||
|
<span>{formatCurrency(totalMensual * IVA_RATE)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm font-bold pt-2 border-t border-border">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{formatCurrency(totalMensual * (1 + IVA_RATE))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { CotizacionForm } from "@/components/CotizacionForm";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function EditarCotizacionPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const [cot, servicios, asesores] = await Promise.all([
|
||||||
|
prisma.cotizacion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
cliente: true,
|
||||||
|
asesor: true,
|
||||||
|
servicios: { include: { servicioCatalogo: true } },
|
||||||
|
planBucefalo: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.servicioCatalogo.findMany({ where: { activo: true }, orderBy: { orden: "asc" }, include: { categoriaRel: true } }),
|
||||||
|
prisma.user.findMany({ orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!cot) notFound();
|
||||||
|
|
||||||
|
const existingData = {
|
||||||
|
id: cot.id,
|
||||||
|
numero: cot.numero,
|
||||||
|
clienteNombre: cot.cliente.nombre,
|
||||||
|
clienteEmpresa: cot.cliente.empresa || "",
|
||||||
|
clienteEmail: cot.cliente.email || "",
|
||||||
|
clienteTelefono: cot.cliente.telefono || "",
|
||||||
|
asesorId: cot.asesorId,
|
||||||
|
asesorNombre: cot.asesor.name,
|
||||||
|
fecha: cot.fecha,
|
||||||
|
vigencia: cot.vigencia,
|
||||||
|
moneda: cot.moneda,
|
||||||
|
tipoCambio: cot.tipoCambio,
|
||||||
|
proyecto: cot.proyecto,
|
||||||
|
esquemaPago: cot.esquemaPago,
|
||||||
|
incluirBonos: cot.incluirBonos,
|
||||||
|
incluirFinanciamiento: cot.incluirFinanciamiento,
|
||||||
|
observaciones: cot.observaciones || "",
|
||||||
|
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||||
|
servicios: cot.servicios.map((s) => ({
|
||||||
|
catalogoId: s.servicioCatalogoId,
|
||||||
|
nombre: s.servicioCatalogo?.nombre || "Servicio",
|
||||||
|
fase: s.fase,
|
||||||
|
tipoPago: s.tipoPago,
|
||||||
|
precio: s.precio,
|
||||||
|
tiempoEntrega: s.tiempoEntrega,
|
||||||
|
entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [],
|
||||||
|
})),
|
||||||
|
estado: cot.estado,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CotizacionForm
|
||||||
|
mode="edit"
|
||||||
|
existingData={existingData}
|
||||||
|
servicios={servicios.map((s) => ({
|
||||||
|
...s,
|
||||||
|
entregablesDefault: s.entregablesDefault as string[],
|
||||||
|
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
|
||||||
|
}))}
|
||||||
|
asesores={asesores}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { formatDate, formatCurrency, IVA_RATE } from "@/lib/calculators";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Pencil } from "lucide-react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ExportExcelButtonSaved, ExportPDFButtonSaved } from "@/components/ExportButtons";
|
||||||
|
import { DeleteCotizacionButton } from "./DeleteButton";
|
||||||
|
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
|
||||||
|
import { PreciosEditables } from "./PreciosEditables";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function CotizacionDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const cot = await prisma.cotizacion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
cliente: true,
|
||||||
|
asesor: true,
|
||||||
|
servicios: { include: { servicioCatalogo: true } },
|
||||||
|
planBucefalo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cot) notFound();
|
||||||
|
|
||||||
|
const serviciosUnicos = cot.servicios
|
||||||
|
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
nombre: s.servicioCatalogo?.nombre || "Servicio",
|
||||||
|
precio: s.precio,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serviciosMensuales = cot.servicios
|
||||||
|
.filter((s) => s.tipoPago === "mensual" && s.seleccionado)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
nombre: s.servicioCatalogo?.nombre || "Servicio",
|
||||||
|
precio: s.precio,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/cotizaciones"
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{cot.numero}</h1>
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
{cot.cliente.empresa || cot.cliente.nombre} | {cot.proyecto}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ExportPDFButtonSaved cotizacionId={cot.id} />
|
||||||
|
<ExportExcelButtonSaved cotizacionId={cot.id} />
|
||||||
|
<Link
|
||||||
|
href={`/cotizaciones/${cot.id}/editar`}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
<DeleteCotizacionButton cotizacionId={cot.id} cotizacionNumero={cot.numero} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
|
||||||
|
<h2 className="font-semibold text-lg mb-4">Informacion de la Cotizacion</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Cliente</span>
|
||||||
|
<p className="font-medium">{cot.cliente.nombre}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Empresa</span>
|
||||||
|
<p className="font-medium">{cot.cliente.empresa || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Asesor</span>
|
||||||
|
<p className="font-medium">{cot.asesor.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Fecha</span>
|
||||||
|
<p className="font-medium">{formatDate(cot.fecha)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Vigencia</span>
|
||||||
|
<p className="font-medium">{formatDate(cot.vigencia)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Moneda</span>
|
||||||
|
<p className="font-medium">{cot.moneda}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Esquema</span>
|
||||||
|
<p className="font-medium">{cot.esquemaPago}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">Estado</span>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<CambiarEstadoButtons cotizacionId={cot.id} estado={cot.estado} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PreciosEditables
|
||||||
|
cotizacionId={cot.id}
|
||||||
|
serviciosUnicos={serviciosUnicos}
|
||||||
|
serviciosMensuales={serviciosMensuales}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cot.observaciones && (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||||
|
<h3 className="font-semibold mb-2">Observaciones</h3>
|
||||||
|
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { CotizacionForm } from "@/components/CotizacionForm";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function NuevaCotizacionPage() {
|
||||||
|
const [servicios, asesores, clientes, paquetes] = await Promise.all([
|
||||||
|
prisma.servicioCatalogo.findMany({
|
||||||
|
where: { activo: true },
|
||||||
|
orderBy: { orden: "asc" },
|
||||||
|
include: { categoriaRel: true },
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({ orderBy: { name: "asc" } }),
|
||||||
|
prisma.cliente.findMany({ orderBy: { nombre: "asc" } }),
|
||||||
|
prisma.paquete.findMany({
|
||||||
|
where: { activo: true },
|
||||||
|
include: {
|
||||||
|
fases: {
|
||||||
|
orderBy: { orden: "asc" },
|
||||||
|
include: { servicios: { include: { servicio: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastCot = await prisma.cotizacion.findFirst({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { numero: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextSeq = 1;
|
||||||
|
if (lastCot) {
|
||||||
|
const match = lastCot.numero.match(/(\d+)$/);
|
||||||
|
if (match) nextSeq = parseInt(match[1]) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CotizacionForm
|
||||||
|
mode="new"
|
||||||
|
servicios={servicios.map((s) => ({
|
||||||
|
...s,
|
||||||
|
entregablesDefault: s.entregablesDefault as string[],
|
||||||
|
categoriaNombre: s.categoriaRel?.nombre || "Sin categoria",
|
||||||
|
}))}
|
||||||
|
asesores={asesores}
|
||||||
|
paquetes={paquetes.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
nombre: p.nombre,
|
||||||
|
fases: p.fases.map((f) => ({
|
||||||
|
nombre: f.nombre,
|
||||||
|
orden: f.orden,
|
||||||
|
servicios: f.servicios.map((sp) => ({
|
||||||
|
id: sp.servicio.id,
|
||||||
|
nombre: sp.servicio.nombre,
|
||||||
|
fase: sp.servicio.fase,
|
||||||
|
tipoPago: sp.servicio.tipoPago,
|
||||||
|
precioBase: sp.servicio.precioBase,
|
||||||
|
tiempoEntrega: sp.servicio.tiempoEntrega,
|
||||||
|
entregablesDefault: Array.isArray(sp.servicio.entregablesDefault) ? sp.servicio.entregablesDefault as string[] : [],
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}))}
|
||||||
|
nextSeq={nextSeq}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Plus, FileText } from "lucide-react";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { CotizacionesList } from "./CotizacionesList";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function CotizacionesPage() {
|
||||||
|
const cotizaciones = await prisma.cotizacion.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { cliente: true, asesor: true, servicios: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Cotizaciones</h1>
|
||||||
|
<p className="text-muted text-sm mt-1">
|
||||||
|
{cotizaciones.length} cotizaciones registradas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/cotizaciones/nueva"
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nueva Cotizacion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cotizaciones.length === 0 ? (
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border p-12 text-center">
|
||||||
|
<FileText className="w-16 h-16 text-muted mx-auto mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold mb-2">
|
||||||
|
No hay cotizaciones
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted mb-4">
|
||||||
|
Comienza creando tu primera cotizacion
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/cotizaciones/nueva"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Crear Cotizacion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CotizacionesList cotizaciones={cotizaciones} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import Sidebar from "@/components/layout/Sidebar";
|
||||||
|
|
||||||
|
export default function AppLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 lg:ml-64">
|
||||||
|
<div className="p-4 lg:p-8 pt-16 lg:pt-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { FileText, Plus, Users, Database } from "lucide-react";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/calculators";
|
||||||
|
import { EstadoBadge } from "@/components/EstadoBadge";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const [
|
||||||
|
totalCotizaciones,
|
||||||
|
cotizacionesRecientes,
|
||||||
|
totalClientes,
|
||||||
|
totalServicios,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.cotizacion.count(),
|
||||||
|
prisma.cotizacion.findMany({
|
||||||
|
take: 10,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { cliente: true, asesor: true, servicios: true },
|
||||||
|
}),
|
||||||
|
prisma.cliente.count(),
|
||||||
|
prisma.servicioCatalogo.count({ where: { activo: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: "Cotizaciones",
|
||||||
|
value: totalCotizaciones,
|
||||||
|
icon: FileText,
|
||||||
|
color: "text-primary",
|
||||||
|
bg: "bg-primary-light",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clientes",
|
||||||
|
value: totalClientes,
|
||||||
|
icon: Users,
|
||||||
|
color: "text-secondary",
|
||||||
|
bg: "bg-purple-100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Servicios Activos",
|
||||||
|
value: totalServicios,
|
||||||
|
icon: Database,
|
||||||
|
color: "text-accent",
|
||||||
|
bg: "bg-cyan-100",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
|
||||||
|
<p className="text-muted text-sm mt-1">
|
||||||
|
Bienvenido al Cotizador E3
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/cotizaciones/nueva"
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors font-medium text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nueva Cotizacion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className="bg-card-bg rounded-xl border border-border p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`${stat.bg} p-2.5 rounded-lg`}>
|
||||||
|
<stat.icon className={`w-5 h-5 ${stat.color}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{stat.value}</p>
|
||||||
|
<p className="text-muted text-sm">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card-bg rounded-xl border border-border">
|
||||||
|
<div className="px-5 py-4 border-b border-border">
|
||||||
|
<h2 className="font-semibold text-lg">Cotizaciones Recientes</h2>
|
||||||
|
</div>
|
||||||
|
{cotizacionesRecientes.length === 0 ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<FileText className="w-12 h-12 text-muted mx-auto mb-3" />
|
||||||
|
<p className="text-muted">
|
||||||
|
No hay cotizaciones. Crea la primera.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/cotizaciones/nueva"
|
||||||
|
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary-dark"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Nueva Cotizacion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
No.
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Cliente
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Fecha
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-5 py-3 font-medium text-muted">
|
||||||
|
Estado
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-5 py-3 font-medium text-muted">
|
||||||
|
Servicios
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cotizacionesRecientes.map((cot) => {
|
||||||
|
const totalUnico = cot.servicios
|
||||||
|
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
|
||||||
|
.reduce((sum, s) => sum + s.precio, 0);
|
||||||
|
const totalMensual = cot.servicios
|
||||||
|
.filter(
|
||||||
|
(s) => s.tipoPago === "mensual" && s.seleccionado
|
||||||
|
)
|
||||||
|
.reduce((sum, s) => sum + s.precio, 0);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={cot.id}
|
||||||
|
className="border-b border-border hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Link
|
||||||
|
href={`/cotizaciones/${cot.id}`}
|
||||||
|
className="text-primary font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{cot.numero}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
{cot.cliente.empresa || cot.cliente.nombre}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-muted">
|
||||||
|
{formatDate(cot.fecha)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<EstadoBadge estado={cot.estado} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right font-medium">
|
||||||
|
{totalUnico > 0 && (
|
||||||
|
<span className="block text-xs text-muted">
|
||||||
|
Unico: {formatCurrency(totalUnico)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalMensual > 0 && (
|
||||||
|
<span className="block text-xs text-muted">
|
||||||
|
Mensual: {formatCurrency(totalMensual)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { compare } from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth";
|
||||||
|
import { loginSchema } from "@/lib/schemas";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = loginSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
if (!user || !user.password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credenciales invalidas" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await compare(password, user.password);
|
||||||
|
if (!valid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Credenciales invalidas" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createSession({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
user: { id: user.id, name: user.name, email: user.email, role: user.role },
|
||||||
|
});
|
||||||
|
response.cookies.set(setSessionCookie(token));
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Error interno" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { clearSessionCookie } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
response.cookies.set(clearSessionCookie());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "No autenticado" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user: session });
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const servicio = await prisma.servicioCatalogo.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
nombre: body.nombre,
|
||||||
|
descripcion: body.descripcion || null,
|
||||||
|
fase: body.fase,
|
||||||
|
tipoPago: body.tipoPago,
|
||||||
|
precioBase: body.precioBase,
|
||||||
|
tiempoEntrega: body.tiempoEntrega,
|
||||||
|
entregablesDefault: body.entregablesDefault || [],
|
||||||
|
variante: body.variante || null,
|
||||||
|
activo: body.activo !== false,
|
||||||
|
orden: body.orden,
|
||||||
|
categoriaId: body.categoriaId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(servicio);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const inUse = await prisma.servicioCotizado.count({
|
||||||
|
where: { servicioCatalogoId: id },
|
||||||
|
});
|
||||||
|
if (inUse > 0) {
|
||||||
|
await prisma.servicioCatalogo.update({
|
||||||
|
where: { id },
|
||||||
|
data: { activo: false },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, archived: true });
|
||||||
|
}
|
||||||
|
await prisma.servicioCatalogo.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true, archived: false });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { servicioCatalogoSchema } from "@/lib/schemas";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const servicios = await prisma.servicioCatalogo.findMany({
|
||||||
|
orderBy: [{ fase: "asc" }, { orden: "asc" }],
|
||||||
|
});
|
||||||
|
return NextResponse.json(servicios);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = servicioCatalogoSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const servicio = await prisma.servicioCatalogo.create({
|
||||||
|
data: {
|
||||||
|
nombre: parsed.data.nombre,
|
||||||
|
descripcion: parsed.data.descripcion,
|
||||||
|
fase: parsed.data.fase,
|
||||||
|
tipoPago: parsed.data.tipoPago,
|
||||||
|
precioBase: parsed.data.precioBase,
|
||||||
|
tiempoEntrega: parsed.data.tiempoEntrega,
|
||||||
|
entregablesDefault: parsed.data.entregablesDefault,
|
||||||
|
categoriaId: parsed.data.categoriaId,
|
||||||
|
variante: parsed.data.variante,
|
||||||
|
activo: true,
|
||||||
|
orden: parsed.data.orden,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(servicio, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const cat = await prisma.categoria.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
nombre: body.nombre,
|
||||||
|
descripcion: body.descripcion || null,
|
||||||
|
color: body.color,
|
||||||
|
orden: body.orden,
|
||||||
|
activo: body.activo !== false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(cat);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const inUse = await prisma.servicioCatalogo.count({ where: { categoriaId: id } });
|
||||||
|
if (inUse > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Tiene ${inUse} servicios asociados. Desactiva la categoria en su lugar.` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.categoria.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const cats = await prisma.categoria.findMany({ orderBy: { orden: "asc" } });
|
||||||
|
return NextResponse.json(cats);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const cat = await prisma.categoria.create({
|
||||||
|
data: {
|
||||||
|
nombre: body.nombre,
|
||||||
|
descripcion: body.descripcion || null,
|
||||||
|
color: body.color || "#6b7280",
|
||||||
|
orden: body.orden ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json(cat, { status: 201 });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const configs = await prisma.configuracion.findMany();
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const c of configs) {
|
||||||
|
map[c.clave] = c.valor;
|
||||||
|
}
|
||||||
|
return NextResponse.json(map);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as Record<string, string>;
|
||||||
|
|
||||||
|
const allowedKeys = [
|
||||||
|
"color_primario",
|
||||||
|
"color_secundario",
|
||||||
|
"logo_base64",
|
||||||
|
"razon_social",
|
||||||
|
"rfc",
|
||||||
|
"domicilio_fiscal",
|
||||||
|
"cuenta_nacional",
|
||||||
|
"clabe_interbancaria",
|
||||||
|
"cuenta_internacional",
|
||||||
|
"cuenta_internacional_swift",
|
||||||
|
"hora_centinela",
|
||||||
|
"anualidad_hosting",
|
||||||
|
"iva",
|
||||||
|
"terminos_condiciones",
|
||||||
|
"no_incluye",
|
||||||
|
"notas_adicionales",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ops = Object.entries(body)
|
||||||
|
.filter(([clave]) => allowedKeys.includes(clave))
|
||||||
|
.map(([clave, valor]) =>
|
||||||
|
prisma.configuracion.upsert({
|
||||||
|
where: { clave },
|
||||||
|
update: { valor },
|
||||||
|
create: { clave, valor },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.$transaction(ops);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Error interno";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user