This commit is contained in:
unknown
2026-05-31 22:07:12 -04:00
153 changed files with 23582 additions and 654 deletions

View File

@@ -120,6 +120,12 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
- **Pain 2:** 🥶 **Se enfrían en WhatsApp** — Mientras estás en obra, el cliente espera respuesta y consulta a la competencia. - **Pain 2:** 🥶 **Se enfrían en WhatsApp** — Mientras estás en obra, el cliente espera respuesta y consulta a la competencia.
- **Pain 3:** 🏃 **Se van con tu trabajo** — Hacen el presupuesto contigo, lo llevan al de al lado para que se lo baje, y tú pierdes una obra que era tuya. - **Pain 3:** 🏃 **Se van con tu trabajo** — Hacen el presupuesto contigo, lo llevan al de al lado para que se lo baje, y tú pierdes una obra que era tuya.
> **Variante implementada en la landing** (`mvp/b2b/landing_reformix.html`, sección "problema") — tres tarjetas por etapa del embudo del reformista:
>
> - **01 · CAPTACIÓN** — *Pagas por el mismo lead que cinco competidores.* / Los portales venden cada contacto varias veces. Ganas el que más rápido llame, no el que mejor trabaje. El cliente acaba agotado de llamadas.
> - **02 · VISITA** — *Conduces 40 km y era cambiar un grifo.* / Sin información previa, cada visita es una apuesta. Una de cada tres no acaba en presupuesto, y muchas de las que sí, no compensan el desplazamiento.
> - **03 · PRESUPUESTO** — *Presupuestas gratis para quien no firma.* / Mides, calculas y montas el PDF: horas de oficina que no factura nadie. La mayoría no acaba en obra, así que ese trabajo lo regalas tú.
### Bloque "Cómo funciona Reformix" ### Bloque "Cómo funciona Reformix"
- **Título:** Le pones la herramienta en tu web. El resto lo hace ella. - **Título:** Le pones la herramienta en tu web. El resto lo hace ella.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# Auth + Multi-tenant + Admin de Planes — Diseño
**Fecha:** 2026-05-30
**Estado:** Aprobado (pendiente de plan de implementación)
**Superficie:** `mvp/b2c` (Next.js 16, Tailwind v4, Drizzle, postgres.js, Vitest)
> **Nota de alcance:** Esta funcionalidad está marcada como **F1.5 / post-hackathon** en `specs.md`
> (multi-tenant real, líneas 39/259/265; pasarela de pago, línea 279). Se adelanta por decisión
> explícita del equipo (mayo 2026) tras abrir la conversación que CLAUDE.md exige para tocar F1.5.
> La demo del 11-jun **no depende** de este módulo; se construye desacoplado del pipeline de voz/render/WhatsApp.
---
## Objetivo
Convertir el panel single-tenant hardcodeado (`TENANT_SLUG` = "Reformas Ejemplo") en un SaaS con login
real, aislamiento de datos por reformista, y un área de administración que gestiona usuarios y asigna
planes. **Stripe queda como stub: sin cobro real** (no hay cuenta activa).
## Decisiones clave (confirmadas en brainstorming)
1. **Multi-tenant real:** cada reformista ve SOLO sus leads/precios/catálogo; el admin ve todos.
2. **Alta de cuentas:** self-signup trial público (RF-A-05) **+** creación/gestión por admin.
3. **Auth propio:** email+password con sesión server-side (sin librería de auth externa).
4. **Planes = etiqueta + estado, sin enforcement:** se asignan y se muestran; no bloquean funciones aún.
5. **La suscripción vive en el `tenant`** (= la cuenta), no en el `user`. Un tenant puede tener varios users.
6. **"Reformas Ejemplo" se reconvierte** en una cuenta logueable real para preservar la demo actual.
---
## Arquitectura de datos
### Tabla nueva: `users`
| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `email` | text unique notNull | |
| `passwordHash` | text notNull | bcryptjs |
| `nombre` | text | |
| `role` | enum `user_role` (`reformista`\|`admin`) notNull default `reformista` | |
| `tenantId` | uuid FK→tenants onDelete cascade, **nullable** | null para admin de plataforma; set para reformista |
| `status` | enum `user_status` (`activo`\|`deshabilitado`) notNull default `activo` | |
| `createdAt` / `updatedAt` | timestamptz notNull defaultNow | |
- Relación **muchos users → un tenant** (soporta "usuarios ilimitados" del plan Business sin migración futura).
- Índice único en `email`. Índice en `tenantId`.
### Tabla nueva: `sessions`
| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `userId` | uuid FK→users onDelete cascade | |
| `tokenHash` | text notNull | hash del token de sesión (el token en claro solo vive en la cookie) |
| `expiresAt` | timestamptz notNull | |
| `createdAt` | timestamptz notNull defaultNow | |
- Índice en `userId`. Índice/único en `tokenHash`.
- Caducidad: 30 días deslizante (se renueva en cada request válido). Sesiones expiradas se ignoran y se limpian perezosamente.
### Tabla nueva: `plans`
| Campo | Tipo | Notas |
|---|---|---|
| `id` | uuid pk | |
| `slug` | text unique (`starter`\|`pro`\|`business`) | |
| `nombre` | text notNull | "Starter" / "Pro" / "Business" |
| `precioMensual` | integer notNull | céntimos: 2900 / 7900 / 19900 |
| `leadsIncluidos` | integer notNull | 5 / 15 / 50 |
| `features` | jsonb `$type<string[]>` notNull default `[]` | bullets del copy |
| `activo` | boolean notNull default true | |
Valores sembrados desde `copy/COPY-GUIDE.md` (§Pricing landing B2B):
- **Starter** — 29 €/mes · 5 leads/mes · 3 €/lead extra · hasta 100 contactos · branding básico.
- **Pro** — 79 €/mes · 15 leads/mes · 2,50 €/lead extra · white-label · sub-flujo licencia urbanística · integraciones Holded/Stel · soporte prioritario.
- **Business** — 199 €/mes · 50 leads/mes · 2 €/lead extra · usuarios ilimitados · API · multi-zona · custom price book · dashboard analytics.
### Columnas nuevas en `tenants`
| Campo | Tipo | Notas |
|---|---|---|
| `planId` | uuid FK→plans, nullable | plan asignado |
| `subscriptionStatus` | enum `subscription_status` (`trial`\|`activo`\|`cancelado`\|`vencido`) notNull default `trial` | |
| `trialEndsAt` | timestamptz nullable | now + 14 días al crear vía signup |
| `stripeCustomerId` | text nullable | reservada para Stripe; sin uso real ahora |
---
## Autenticación
- **Hashing:** `bcryptjs` (JS puro, evita builds nativos en el Docker Node-22-slim). Dep nueva justificada en el commit.
- **Sesión server-side:** token aleatorio (`crypto.randomBytes`) → se guarda **hasheado** en `sessions.tokenHash`; el token en claro va en cookie `session` (`httpOnly`, `secure`, `sameSite: lax`, `path: /`).
- **Helpers** en `src/lib/auth/`:
- `hashPassword(plain)` / `verifyPassword(plain, hash)`
- `createSession(userId)` → setea cookie · `destroySession()` → logout
- `getCurrentUser()` → lee cookie, valida sesión, devuelve user o null
- `getCurrentTenantId()` → deriva del user logueado (lanza si no hay)
- `requireUser()` / `requireAdmin()` → para Server Components / actions; redirigen si falta permiso
- **`getCurrentTenantId()` reemplaza** la resolución por `TENANT_SLUG` en `src/app/panel/actions.ts` y `src/db/pricing-queries.ts`. Las queries ya filtran por `tenantId`; solo cambia la **fuente** del id.
---
## Superficies / rutas
| Ruta | Acceso | Función |
|---|---|---|
| `/signup` | público | RF-A-05: form email+nombre+empresa+provincia+opt-in → crea `tenant` + `user` (owner, role reformista) + `subscriptionStatus=trial`, `trialEndsAt=now+14d`. Redirige a `/panel`. Cableado desde el CTA "Empieza gratis 14 días" de la landing B2B |
| `/login` | público | email+password → crea sesión → `/panel` |
| `/logout` | autenticado | destruye sesión → `/login` |
| `/panel/*` | reformista | guard en `panel/layout.tsx`: sin sesión → redirect `/login`. Muestra SOLO datos de su tenant. Badge de plan/estado en la cabecera |
| `/admin` | admin | `requireAdmin`. Lista tenants + usuarios; ver planes |
| `/admin/usuarios` | admin | crear reformista (crea tenant+user owner), habilitar/deshabilitar usuarios |
| `/admin/planes` | admin | ver catálogo de planes; **asignar plan + `subscriptionStatus`** a un tenant |
> El área admin reutiliza el estilo del panel existente (Tailwind v4, sin shadcn). Copy nuevo que no exista
> se añade primero a `copy/COPY-GUIDE.md` antes de usarse (regla CLAUDE.md).
---
## Control de acceso
- **Reformista:** todas las lecturas/mutaciones se filtran por su `tenantId` (vía `getCurrentTenantId()`). No puede acceder a leads, precios ni catálogo de otro tenant. Acceso a `/admin/*` → 403/redirect.
- **Admin** (`tenantId = null`, `role = admin`): ve y gestiona todos los tenants y usuarios; asigna planes y cambia estado de suscripción. No tiene un "panel de leads" propio (no posee tenant); opcionalmente puede impersonar/ver un tenant en modo lectura — **fuera de alcance ahora**.
---
## Planes (sin enforcement)
- El admin asigna `planId` y `subscriptionStatus` a un tenant desde `/admin/planes`.
- `/panel` muestra un badge: p. ej. *"Plan Pro · trial, 9 días restantes"* (calculado desde `trialEndsAt`).
- **No se bloquea ninguna función por plan ni por caducidad de trial** en esta fase. `trialEndsAt` se calcula y se muestra pero no corta el acceso. El gating efectivo se decidirá más adelante.
- **Stripe stub:** `src/lib/billing/stripe.ts` define la interfaz (`createCustomer`, `createCheckoutSession`, etc.) pero **no hace llamadas reales**; lanza o devuelve no-op documentado. Botón "Gestionar pago" en el panel deshabilitado con copy "Próximamente". `tenants.stripeCustomerId` queda reservado.
---
## Migración y seed
- **Migración Drizzle nueva:** crea enums (`user_role`, `user_status`, `subscription_status`), tablas (`users`, `sessions`, `plans`) y columnas nuevas en `tenants`. Solo añade; no rompe datos existentes.
- **Seed** (gateado por `SEED_FORCE`, comportamiento TRUNCATE+reinsert ya existente):
- Siembra los 3 `plans` (Starter/Pro/Business).
- Crea **1 admin** (email+password de demo, documentado).
- Reconvierte "Reformas Ejemplo" en tenant con **1 user owner logueable** (email+password de demo), `planId` = Pro, `subscriptionStatus` = trial, `trialEndsAt` = now+14d — así toda la demo de leads/presupuesto actual sigue accesible tras login.
---
## Testing (Vitest)
- **Auth helpers:** `hashPassword`/`verifyPassword` (round-trip y rechazo), `createSession`/validación, caducidad de sesión.
- **Control de acceso:** reformista del tenant A no obtiene leads del tenant B; admin sí ve ambos; usuario no-admin es rechazado en acciones admin.
- **Signup:** crea tenant + user owner + trial con `trialEndsAt` correcto; email duplicado rechazado.
- **Asignación de plan:** admin asigna `planId`/`subscriptionStatus`; se refleja en el tenant.
- Cobertura objetivo de la lógica de auth/acceso alineada con RNF-MAINT-01 (≥70% en módulos nuevos críticos).
---
## Fases de implementación
1. **Auth base** — enums + tablas `users`/`sessions`; helpers `src/lib/auth/`; `/login` + `/logout`; guard en `panel/layout`; migrar `getTenantId` a sesión; seed admin + owner de "Reformas Ejemplo".
2. **Signup trial**`/signup` público + cableado desde la landing B2B (CTA "Empieza gratis 14 días"); creación tenant+owner+trial; onboarding mínimo (redirect a `/panel`).
3. **Área admin**`/admin`, `/admin/usuarios`, `/admin/planes`; seed de los 3 planes; asignación de plan/estado.
4. **Stripe stub + display de plan**`src/lib/billing/stripe.ts` (interfaz no-op); badge de plan/estado en `/panel`; botón "Gestionar pago" deshabilitado.
---
## Fuera de alcance (explícito)
- Cobro real / checkout Stripe / webhooks de facturación.
- Enforcement de límites por plan (leads/mes, renders) y bloqueo por trial vencido.
- OAuth / login social, recuperación de contraseña por email, verificación de email.
- Impersonación de tenant por el admin (vista de leads ajena).
- Gestión multi-usuario dentro de un mismo tenant desde el panel del reformista (el schema lo soporta, la UI no se construye ahora).

View File

@@ -0,0 +1,137 @@
# Motor de presupuesto Reformix — Diseño
> Fecha: 2026-05-30 · Estado: aprobado (brainstorming) · Owner dominio: Goyo · Coord: Carlos
> Alcance: F2 (en sprint actual). El configurador multi-tenant real sigue siendo F1.5.
## Objetivo
Producir un presupuesto orientativo desglosado por partidas a partir de datos que
escalan "de menos a más": con el mínimo (tipo de reforma + calidad) ya sale un número,
y cada etapa del funnel (medidas, material exacto, llamada) lo afina. El reformista
define los precios en el panel; el catálogo se puede sembrar y actualizar por CSV.
Requisitos cubiertos: RF-C-21 (desglose por partidas + factor zona), RF-C-22 (licencia
si hay cambios estructurales), RF-D-07 (tabla de precios editable), RF-B-07 (DIN-A4 como
input de medidas, *fallback* stubbeable), RF-B-09 (disclaimer orientativo),
RNF-MAINT-01 (≥70% cobertura en `src/budget/*`).
## Decisiones de diseño (validadas con el usuario)
1. **Modelo híbrido partidas ← precios unitarios.** El reformista configura precios
unitarios (€/m² suelo por calidad, €/m² pared, €/m² pintura, €/ml mobiliario, mano
de obra). El motor calcula cantidades desde las medidas y agrupa el resultado en las
partidas de RF-C-21.
2. **Medidas mínimas = m² de suelo + supuestos.** El resto se deriva: perímetro ≈ 4·√(m²),
m² pared = perímetro × altura (2,5 m por defecto). Si no hay m², mediana por tipo.
El refinamiento con dimensiones reales (largo×ancho×alto) o DIN-A4 es posterior/opcional.
3. **Calidad = columna de precio por material (B/M/P)** más un **catálogo de materiales**
con precio e identidad propia, importable por CSV. El cliente elige una calidad global
por defecto; puede personalizar material exacto si quiere.
4. **Progressive disclosure.** Se fomenta lo básico (solo calidad). La personalización
(material exacto del catálogo) aparece sutil y opcional en el funnel, y el agente la
afina en la llamada. El material elegido alimenta el prompt del render para que la
imagen refleje exactamente lo presupuestado.
## Arquitectura
```
PANEL (CRUD) pricing_config + catalog_items (por tenant)
reformista · precios unitarios B/M/P por material
+ CSV import · mano de obra, factor zona, partidas
│ (lee de DB)
FUNNEL (cliente)
inputs por etapa ─► computeBudget(config, catalog, inputs) ─► BudgetResult
· m² suelo, calidad [ función PURA en src/budget/ ] · partidas[]
· (opc.) material exacto · subtotal, total
· (llamada) estructural · rango + confianza
· materiales→render
```
- **`src/budget/` (núcleo puro):** tipos, `computeBudget()`, derivación de cantidades,
agrupación en partidas, partida condicional de licencia, cálculo de rango+confianza.
Sin imports de DB ni de red. Es el módulo con cobertura ≥70% (RNF-MAINT-01).
- **DB (Drizzle):** extiende el schema existente (`src/db/schema.ts`).
- **Panel:** CRUD sobre config/catálogo + importador CSV. Tenant único "Reformas Ejemplo".
- **Funnel:** recoge inputs mínimos, llama al engine, persiste snapshot por etapa.
## Flujo de cálculo `computeBudget(config, catalog, inputs)`
1. **Cantidades** (con degradación):
- `m² suelo` aportado; si no, mediana por tipo (cocina 10, baño 5, salón 20, integral 70).
- `perímetro ≈ 4·√(m² suelo)`; `m² pared = perímetro × alturaTecho` (default 2,5 m).
- `ml mobiliario ≈ perímetro × factor_tipo` (solo cocina/baño).
2. **Precio unitario** por material: ítem exacto elegido del catálogo si existe; si no,
ítem `esDefault` de la calidad global.
3. **Partidas** (RF-C-21): cada partida = Σ(cantidad × precio unitario material) + mano de
obra. Categorías: demolición, alicatado, fontanería, electricidad, carpintería, mano de
obra, extras.
4. **Factor zona:** multiplicador por provincia (configurable) sobre el subtotal.
5. **Licencia** (RF-C-22): si `inputs.estructural = true`, partida "Licencia + Proyecto
técnico" con rango 3001.500 €.
6. **Rango + confianza:** total como `{ min, max, confianza }`. Menos datos → banda más
ancha (≈ ±25% solo con calidad; ≈ ±10% tras llamada con material exacto + estructural
confirmado).
## Modelo de datos (extensión Drizzle)
```
pricing_config (1 por tenant)
tenantId, alturaTechoDefault, factorZona (jsonb provincia→mult),
manoObra (jsonb partida→€), updatedAt
catalog_items (N por tenant)
id, tenantId, categoria (suelo|pared|pintura|mobiliario|...),
nombre, calidad (basica|media|premium), precioUnit (cents),
unidad (m2|ml|ud), descriptorRender (text), esDefault (bool), sku
leads (campos nuevos)
m2Suelo, alturaTecho, calidadGlobal, estructural,
materialSelections (jsonb categoria→catalogItemId),
desgloseSnapshot (jsonb: partidas+rango por pipeline_stage)
```
- Dinero en **enteros (cents)**, consistente con el schema actual.
- `desgloseSnapshot` guarda el resultado **en cada `pipeline_stage`** → permite analizar
cómo evoluciona la estimación lead a lead.
- `descriptorRender` de cada material se inyecta en el prompt del render.
## Panel del reformista (`/panel/precios`)
- **Catálogo editable:** tabla de `catalog_items` por categoría, precio por calidad inline;
crear/editar/borrar; marcar `esDefault` por calidad.
- **Importar CSV:** subida → parse con zod → preview filas válidas/erróneas → confirmar.
Cabeceras: `categoria,nombre,calidad,precio,unidad,descriptor_render,sku`. *Upsert* por
`sku`. Si hay errores de validación, se escriben **cero** filas.
- **Config general:** factor zona por provincia, mano de obra por partida, altura techo.
## Funnel (cliente) — progressive disclosure
- Por defecto: **tipo de reforma + calidad (B/M/P)** y, opcional, m² de suelo → estimación
+ render genérico de la calidad.
- Afordance sutil **"Personalizar materiales"** (colapsado): galería del catálogo para
elegir ítems exactos. Quien no la toca, usa el default.
- La **llamada** enriquece inputs (material exacto, estructural, medidas finas) → recálculo
+ render exacto.
## Seed (demo 11-jun-2026)
Sembrar `pricing_config` + un **catálogo demo** (suelos, alicatados, pinturas, mobiliario
en B/M/P con `descriptorRender`) para que la demo funcione sin cargar CSV.
## Fuera de alcance (ahora)
- Recálculo retroactivo de presupuestos ya guardados (los snapshots no se tocan).
- Calidad por elemento mezclada como input por defecto (refinamiento F1.5; el catálogo ya
lo permite a nivel de selección manual).
- Extracción real de medidas por visión/DIN-A4: se deja **stub** (`has_din_a4` flag +
hook de medidas) y se usa la degradación por medianas mientras tanto.
- Multi-tenant real (F1.5): todo opera sobre "Reformas Ejemplo".
- Dimensiones largo×ancho×alto como input mínimo (queda como refinamiento opcional).
## Verificación
- Tests unitarios de `computeBudget` con inputs conocidos: desglose ±1 € vs cálculo manual
(RF-C-21), partida de licencia presente con `estructural=true` (RF-C-22), degradación sin
m², estrechamiento del rango al añadir datos. Cobertura `src/budget/*` ≥70% (RNF-MAINT-01).
- Test de parser CSV: filas válidas/erróneas, upsert por sku, cero escrituras si hay error.

View File

@@ -0,0 +1,149 @@
# Guion del agente de voz + capa de preferencias — Diseño
**Fecha:** 2026-05-31
**Estado:** aprobado (brainstorming), pendiente de plan de implementación
**Superficie:** funnel B2C (`mvp/b2c`), bloque de llamada del agente (RF-C) + panel (RF-D)
## Objetivo
Plantear el guion del agente de voz para que recoja datos adicionales sobre los gustos del cliente, y una capa que clasifique y abstraiga ese texto libre en inputs que el motor de presupuesto pueda usar — mejorando la precisión y el acercamiento estético del presupuesto sin romper la trazabilidad partida-a-partida del motor.
## Decisiones de alcance (validadas)
- **Cuatro palancas** por las que los gustos afectan al presupuesto:
1. Selección de material + calidad por categoría (vía catálogo).
2. Detección de elementos/extras → partidas nuevas.
3. Descriptores de render.
4. Modificadores de € **etiquetados y trazables** (nunca % opaco).
- **Estilo de guion:** híbrido — slots fijos + bloque abierto de gustos.
- **Capa de clasificación:** clasificador keyless funcional + esquema durable, con costura para enchufar GPT-4o en F2.
- **Arquitectura:** Enfoque A — pre + post alrededor de `computeBudget`; el motor queda intacto.
## Arquitectura
```
RawCallData
→ abstractPreferences(raw, catalog) → AbstractedPreferences
→ mergeIntoBudgetInputs(prefs, lead) → BudgetInputs [pre]
→ computeBudget(inputs, config, catalog) → BudgetResult [motor intacto]
→ applyPreferences(result, prefs) → BudgetResult final [post: elementos + ajustes]
```
El motor de presupuesto (`src/budget`) no se modifica. La extracción y la aplicación de extras viven en un módulo nuevo `src/lib/voice`, con interfaces bien definidas.
## Componentes
### 1. Contrato de datos — `src/lib/voice/preferences.ts`
```ts
import type { Calidad, CategoriaMaterial, TipoReforma } from '@/budget/types';
export interface RawCallData {
tipoReforma: TipoReforma;
m2Suelo: number | null;
calidad: Calidad | null;
estructural: boolean | null;
urgencia: 'alta' | 'media' | 'baja' | null;
presupuestoTarget: number | null; // céntimos
tasteText: string; // bloque abierto del guion
}
export interface PreferenceExtra { // palanca 2 → se convierte en partida
key: string; // 'isla_cocina'
label: string; // 'Isla de cocina'
importe: number; // céntimos (base, antes de factor zona)
}
export interface PreferenceAjuste { // palanca 4 → ajuste etiquetado y trazable
label: string;
tipo: 'factor' | 'fijo';
valor: number; // factor (1.15) o céntimos
motivo: string; // visible en el panel
}
export interface AbstractedPreferences {
calidadGlobal: Calidad;
materialSelections: Partial<Record<CategoriaMaterial, string>>; // catalogItemId
estructural: boolean;
urgencia: 'alta' | 'media' | 'baja' | null;
presupuestoTarget: number | null;
elementos: PreferenceExtra[];
estiloRender: string[];
ajustes: PreferenceAjuste[];
confianza: 'baja' | 'media' | 'alta';
resumen: string; // 1-2 frases para el panel
camposFaltantes: string[]; // RF-C-15 completeness
}
```
Notas:
- No hay "calidad por categoría" separada: es expresable vía `materialSelections` (cada `CatalogItem` lleva su `calidad`).
- Los modificadores (palanca 4) son siempre `PreferenceAjuste` con `label` + `motivo`.
- Los `elementos` se inyectan como partidas tras `computeBudget`, reusando que `PartidaResult.key` admite string libre.
### 2. Guion — `src/lib/voice/script.ts`
`buildScript(reformistaConfig, lead)` → estructura de bloques/turnos (RF-C-14, generado desde la config del reformista).
- **Bloque 0 — Preámbulo legal (literal, obligatorio):** identificación IA + aviso de grabación (RF-C-12, RNF-LEG-05). Si responde "no" → cierre educado + `consent_revoked` (RF-C-13).
- **Bloque 1 — Confirmación:** tipo y m² del form.
- **Bloque 2 — Slots fijos (uno a uno, opciones acotadas):** calidad (básica/media/premium), estructural (sí/no), urgencia (alta/media/baja), presupuesto target (opcional).
- **Bloque 3 — Bloque abierto de gustos:** pregunta ancla *"Cuéntame cómo te lo imaginas: estilo, colores, materiales… y si hay algún capricho que no quieras que falte."* + hasta 2 repreguntas guiadas (materiales, elemento estrella). Todo alimenta `tasteText`.
- **Bloque 4 — Cierre + completeness (RF-C-15/16):** repregunta dirigida si faltan campos críticos; tope 8 min → info parcial; despedida.
Hoy el orquestador usa la estructura para construir una transcripción realista; en F2 se traduce al prompt de Retell sin redeploy.
### 3. Clasificador — `src/lib/voice/extractor.ts` + `src/lib/voice/lexicon.ts`
```ts
export interface PreferenceExtractor {
extract(raw: RawCallData, catalog: CatalogItem[]): AbstractedPreferences;
}
// hoy: DeterministicExtractor | F2: GPT4oExtractor (misma interfaz)
```
`DeterministicExtractor` (keyless):
1. Normaliza `tasteText` (minúsculas, sin tildes) y lo trocea en señales.
2. **Calidad:** `CALIDAD_LEXICON` (premium/básica/media). Gana el slot explícito si existe.
3. **Materiales por categoría:** matchea keywords contra `nombre` + `descriptorRender` de los `CatalogItem` del tenant; elige item de la calidad detectada; devuelve `catalogItemId`.
4. **Elementos/extras:** `ELEMENTOS_LEXICON` por tipo de reforma → `PreferenceExtra` con importe base versionado en la tabla (no inventado en runtime).
5. **Estructural:** `ESTRUCTURAL_LEXICON` (*tirar muro, mover el baño, abrir la cocina*) → refuerza el slot.
6. **Estilo render:** `ESTILO_LEXICON``estiloRender[]`, que se concatena con `materialesRender` del motor.
7. **Ajustes etiquetados:** reglas explícitas (*encimera de mármol/cuarzo* → `PreferenceAjuste` con motivo).
8. **Confianza + camposFaltantes:** alta si slots críticos + señales claras; baja si texto pobre. Lista campos RF-C-15 ausentes.
Léxicos en español, en `lexicon.ts`, ampliables y unit-testeables.
### 4. Aplicación — `src/lib/voice/apply.ts`
- `mergeIntoBudgetInputs(prefs, lead): BudgetInputs` — vuelca calidad, `materialSelections`, `estructural`, provincia/m² del lead.
- `applyPreferences(result, prefs): BudgetResult` — añade `prefs.elementos` como partidas, aplica `prefs.ajustes` (factor o fijo) recalculando `subtotal`/`total`/`rango`, y concatena `prefs.estiloRender` a `materialesRender`. Cada extra/ajuste queda como partida o aviso con label visible.
## Integración en el funnel
- `FotosUploader.tsx`: + select urgencia, + checkbox estructural, + input target, + textarea "Cuéntanos cómo lo imaginas" (stand-in keyless del bloque 3; cierra hueco RF-C-15).
- `actions.ts` (`guardarDetallesYFotos`): persiste los campos nuevos en el lead.
- `orchestrator.ts`: construye `RawCallData` desde el lead → `abstractPreferences``mergeIntoBudgetInputs``computeBudget``applyPreferences`. La transcripción simulada pasa a generarse desde `buildScript` + respuestas reales.
- `schema.ts`: campos nuevos en `leads` (`urgencia`, `presupuestoTarget`, `tasteText`) + snapshot de `AbstractedPreferences`.
- Panel detalle (`panel/[id]/page.tsx`): sección "Preferencias detectadas" con `resumen`, `elementos`, `ajustes` (con motivo) y `estiloRender`. El reformista edita extras vía el `ConceptosEditor` ya existente.
## Manejo de errores
- `tasteText` vacío → `AbstractedPreferences` con `confianza: 'baja'`, sin elementos/ajustes, `materialSelections` por defecto; el presupuesto sigue calculándose con los slots.
- Sin match de material en catálogo → se omite esa categoría (cae al default del motor), no se inventa item.
- Ajuste sin importe resoluble → se descarta y se anota en `camposFaltantes`.
## Testing (TDD)
- `extractor.test.ts`: textos de gusto conocidos → `AbstractedPreferences` esperadas (calidad, materiales, elementos, estilo, estructural, confianza).
- `apply.test.ts`: `BudgetResult` + prefs → partidas extra correctas y total con ajustes (±1 €).
- `script.test.ts`: `buildScript` incluye el preámbulo legal literal y todos los bloques.
## Compliance
RF-C-12 (aviso de grabación), RNF-LEG-05 (identificación IA), RF-C-13 (revoca consentimiento), RF-C-16 (tope 8 min), RF-C-15 (7 campos obligatorios).
## Fuera de alcance
- Llamada real (Retell), telefonía (Zadarma), transcripción/Vision real (GPT-4o): F2 con claves. El `GPT4oExtractor` se deja como costura, no se implementa.
- 3 versiones B/M/P del presupuesto y refinamiento por lenguaje natural post-envío: F1.5.

4
mvp/b2b/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
COPY assets /usr/share/nginx/html/assets
EXPOSE 80

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2F5C46"/>
<text x="32" y="48" text-anchor="middle" font-family="Georgia, 'Instrument Serif', 'Times New Roman', serif" font-style="italic" font-size="48" fill="#F6F4EF">R</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

2136
mvp/b2b/index.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

3
mvp/b2c/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"

1
mvp/b2c/.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
# vercel # vercel
.vercel .vercel

View File

@@ -13,5 +13,12 @@ COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Necesario para migrar y sembrar al arrancar (drizzle-kit + tsx + seed)
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/tsconfig.json ./tsconfig.json
COPY --from=builder /app/src ./src
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "start"] CMD ["./docker-entrypoint.sh"]

View File

@@ -91,6 +91,14 @@ npm run lint # ESLint — análisis estático del código
--- ---
## 💶 Panel y motor de presupuesto
- **`/panel`** — listado de leads y detalle (`/panel/[id]`) con presupuesto desglosado y botón *Recalcular*.
- **`/panel/precios`** — tabla de precios editable (config general + catálogo por categoría) e importación de catálogo vía CSV.
- **Motor** (`src/budget/`) — función pura `computeBudget` que calcula el presupuesto por partidas a partir de medidas mínimas + calidad, escalando con más datos.
---
## 🔗 Repositorio ## 🔗 Repositorio
GitHub: [McGregory99/reformix-hackaton](https://github.com/McGregory99/reformix-hackaton) GitHub: [McGregory99/reformix-hackaton](https://github.com/McGregory99/reformix-hackaton)

View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
echo "==> Aplicando migraciones (drizzle-kit migrate)"
npm run db:migrate
echo "==> Sembrando datos demo (si la DB está vacía)"
npm run db:seed
echo "==> Arrancando Next.js"
exec npm run start

13
mvp/b2c/drizzle.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,77 @@
CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido');--> statement-breakpoint
CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado');--> statement-breakpoint
CREATE TYPE "public"."tipo_reforma" AS ENUM('cocina', 'bano', 'salon', 'comedor', 'integral', 'otro');--> statement-breakpoint
CREATE TABLE "lead_estado_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"estado" "lead_estado" NOT NULL,
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
"changed_by" text
);
--> statement-breakpoint
CREATE TABLE "lead_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"url" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lead_pipeline_eventos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"stage" "pipeline_stage" NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb
);
--> statement-breakpoint
CREATE TABLE "leads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"nombre" text NOT NULL,
"telefono" text NOT NULL,
"email" text NOT NULL,
"provincia" text,
"tipo_reforma" "tipo_reforma",
"consent_privacidad" boolean DEFAULT false NOT NULL,
"consent_contratacion" boolean DEFAULT false NOT NULL,
"pipeline_stage" "pipeline_stage" DEFAULT 'form_completado' NOT NULL,
"estado" "lead_estado" DEFAULT 'nuevo' NOT NULL,
"presupuesto_estimado" integer,
"transcripcion" text,
"entidades" jsonb,
"render_url" text,
"pdf_url" text,
"audio_url" text,
"notas" text
);
--> statement-breakpoint
CREATE TABLE "precision_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"estimated" integer NOT NULL,
"final" integer NOT NULL,
"delta_pct" numeric(6, 2) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tenants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre_empresa" text NOT NULL,
"logo_url" text,
"provincia" text,
"whatsapp_business" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tenants_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_pipeline_eventos" ADD CONSTRAINT "lead_pipeline_eventos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "precision_history" ADD CONSTRAINT "precision_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "leads_tenant_created_idx" ON "leads" USING btree ("tenant_id","created_at");--> statement-breakpoint
CREATE INDEX "leads_estado_idx" ON "leads" USING btree ("estado");

View File

@@ -0,0 +1,36 @@
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');--> statement-breakpoint
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');--> statement-breakpoint
CREATE TYPE "public"."unidad_medida" AS ENUM('m2', 'ml', 'ud');--> statement-breakpoint
CREATE TABLE "catalog_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"categoria" "categoria_material" NOT NULL,
"nombre" text NOT NULL,
"calidad" "calidad" NOT NULL,
"precio_unit" integer NOT NULL,
"unidad" "unidad_medida" NOT NULL,
"descriptor_render" text DEFAULT '' NOT NULL,
"es_default" boolean DEFAULT false NOT NULL,
"sku" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pricing_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"altura_techo_default" double precision DEFAULT 2.5 NOT NULL,
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
);
--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "m2_suelo" double precision;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "altura_techo" double precision;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "calidad_global" "calidad";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estructural" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "desglose_snapshot" jsonb;--> statement-breakpoint
ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pricing_config" ADD CONSTRAINT "pricing_config_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id");--> statement-breakpoint
CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku");

View File

@@ -0,0 +1,45 @@
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin');--> statement-breakpoint
CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado');--> statement-breakpoint
CREATE TABLE "plans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"precio_mensual" integer NOT NULL,
"leads_incluidos" integer NOT NULL,
"features" jsonb DEFAULT '[]'::jsonb NOT NULL,
"activo" boolean DEFAULT true NOT NULL,
CONSTRAINT "plans_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"nombre" text,
"role" "user_role" DEFAULT 'reformista' NOT NULL,
"tenant_id" uuid,
"status" "user_status" DEFAULT 'activo' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "plan_id" uuid;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "subscription_status" "subscription_status" DEFAULT 'trial' NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "trial_ends_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id");--> statement-breakpoint
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "envio_presupuesto" "envio_presupuesto_mode" DEFAULT 'automatico' NOT NULL;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "tenants" ADD COLUMN "cif" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "direccion" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "telefono" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "email" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "web" text;

View File

@@ -0,0 +1,5 @@
CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja');--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "urgencia" "urgencia";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "presupuesto_target" integer;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "taste_text" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "preferences_snapshot" jsonb;

View File

@@ -0,0 +1,561 @@
{
"id": "66acce06-f292-49db-adc1-fa9cfcc7d2a9",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.lead_estado_history": {
"name": "lead_estado_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"changed_at": {
"name": "changed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_estado_history_lead_id_leads_id_fk": {
"name": "lead_estado_history_lead_id_leads_id_fk",
"tableFrom": "lead_estado_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_fotos": {
"name": "lead_fotos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"orden": {
"name": "orden",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lead_fotos_lead_id_leads_id_fk": {
"name": "lead_fotos_lead_id_leads_id_fk",
"tableFrom": "lead_fotos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_pipeline_eventos": {
"name": "lead_pipeline_eventos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"stage": {
"name": "stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"occurred_at": {
"name": "occurred_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_pipeline_eventos_lead_id_leads_id_fk": {
"name": "lead_pipeline_eventos_lead_id_leads_id_fk",
"tableFrom": "lead_pipeline_eventos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leads": {
"name": "leads",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"telefono": {
"name": "telefono",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tipo_reforma": {
"name": "tipo_reforma",
"type": "tipo_reforma",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"consent_privacidad": {
"name": "consent_privacidad",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"consent_contratacion": {
"name": "consent_contratacion",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"pipeline_stage": {
"name": "pipeline_stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'form_completado'"
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'nuevo'"
},
"presupuesto_estimado": {
"name": "presupuesto_estimado",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"transcripcion": {
"name": "transcripcion",
"type": "text",
"primaryKey": false,
"notNull": false
},
"entidades": {
"name": "entidades",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"render_url": {
"name": "render_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"audio_url": {
"name": "audio_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notas": {
"name": "notas",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"leads_tenant_created_idx": {
"name": "leads_tenant_created_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"leads_estado_idx": {
"name": "leads_estado_idx",
"columns": [
{
"expression": "estado",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leads_tenant_id_tenants_id_fk": {
"name": "leads_tenant_id_tenants_id_fk",
"tableFrom": "leads",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.precision_history": {
"name": "precision_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estimated": {
"name": "estimated",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"final": {
"name": "final",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delta_pct": {
"name": "delta_pct",
"type": "numeric(6, 2)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"precision_history_lead_id_leads_id_fk": {
"name": "precision_history_lead_id_leads_id_fk",
"tableFrom": "precision_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenants": {
"name": "tenants",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nombre_empresa": {
"name": "nombre_empresa",
"type": "text",
"primaryKey": false,
"notNull": true
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"whatsapp_business": {
"name": "whatsapp_business",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenants_slug_unique": {
"name": "tenants_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.lead_estado": {
"name": "lead_estado",
"schema": "public",
"values": [
"nuevo",
"contactado",
"visita_agendada",
"presupuesto_enviado",
"ganado",
"perdido"
]
},
"public.pipeline_stage": {
"name": "pipeline_stage",
"schema": "public",
"values": [
"form_completado",
"fotos_subidas",
"prellamada_enviada",
"llamada_completada",
"render_generado",
"presupuesto_generado",
"whatsapp_entregado"
]
},
"public.tipo_reforma": {
"name": "tipo_reforma",
"schema": "public",
"values": [
"cocina",
"bano",
"salon",
"comedor",
"integral",
"otro"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,834 @@
{
"id": "57e8d006-18f6-4aba-a61a-02d155a80bbc",
"prevId": "66acce06-f292-49db-adc1-fa9cfcc7d2a9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.catalog_items": {
"name": "catalog_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"categoria": {
"name": "categoria",
"type": "categoria_material",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"calidad": {
"name": "calidad",
"type": "calidad",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"precio_unit": {
"name": "precio_unit",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"unidad": {
"name": "unidad",
"type": "unidad_medida",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"descriptor_render": {
"name": "descriptor_render",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"es_default": {
"name": "es_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sku": {
"name": "sku",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"catalog_tenant_idx": {
"name": "catalog_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"catalog_tenant_sku_idx": {
"name": "catalog_tenant_sku_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"catalog_items_tenant_id_tenants_id_fk": {
"name": "catalog_items_tenant_id_tenants_id_fk",
"tableFrom": "catalog_items",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_estado_history": {
"name": "lead_estado_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"changed_at": {
"name": "changed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_estado_history_lead_id_leads_id_fk": {
"name": "lead_estado_history_lead_id_leads_id_fk",
"tableFrom": "lead_estado_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_fotos": {
"name": "lead_fotos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"orden": {
"name": "orden",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lead_fotos_lead_id_leads_id_fk": {
"name": "lead_fotos_lead_id_leads_id_fk",
"tableFrom": "lead_fotos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_pipeline_eventos": {
"name": "lead_pipeline_eventos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"stage": {
"name": "stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"occurred_at": {
"name": "occurred_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_pipeline_eventos_lead_id_leads_id_fk": {
"name": "lead_pipeline_eventos_lead_id_leads_id_fk",
"tableFrom": "lead_pipeline_eventos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leads": {
"name": "leads",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"telefono": {
"name": "telefono",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tipo_reforma": {
"name": "tipo_reforma",
"type": "tipo_reforma",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"consent_privacidad": {
"name": "consent_privacidad",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"consent_contratacion": {
"name": "consent_contratacion",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"pipeline_stage": {
"name": "pipeline_stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'form_completado'"
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'nuevo'"
},
"presupuesto_estimado": {
"name": "presupuesto_estimado",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"transcripcion": {
"name": "transcripcion",
"type": "text",
"primaryKey": false,
"notNull": false
},
"entidades": {
"name": "entidades",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"render_url": {
"name": "render_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"audio_url": {
"name": "audio_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notas": {
"name": "notas",
"type": "text",
"primaryKey": false,
"notNull": false
},
"m2_suelo": {
"name": "m2_suelo",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"altura_techo": {
"name": "altura_techo",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"calidad_global": {
"name": "calidad_global",
"type": "calidad",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"estructural": {
"name": "estructural",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"material_selections": {
"name": "material_selections",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"desglose_snapshot": {
"name": "desglose_snapshot",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"leads_tenant_created_idx": {
"name": "leads_tenant_created_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"leads_estado_idx": {
"name": "leads_estado_idx",
"columns": [
{
"expression": "estado",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leads_tenant_id_tenants_id_fk": {
"name": "leads_tenant_id_tenants_id_fk",
"tableFrom": "leads",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.precision_history": {
"name": "precision_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estimated": {
"name": "estimated",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"final": {
"name": "final",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delta_pct": {
"name": "delta_pct",
"type": "numeric(6, 2)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"precision_history_lead_id_leads_id_fk": {
"name": "precision_history_lead_id_leads_id_fk",
"tableFrom": "precision_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pricing_config": {
"name": "pricing_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"altura_techo_default": {
"name": "altura_techo_default",
"type": "double precision",
"primaryKey": false,
"notNull": true,
"default": 2.5
},
"factor_zona": {
"name": "factor_zona",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"mano_obra": {
"name": "mano_obra",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pricing_config_tenant_id_tenants_id_fk": {
"name": "pricing_config_tenant_id_tenants_id_fk",
"tableFrom": "pricing_config",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pricing_config_tenant_id_unique": {
"name": "pricing_config_tenant_id_unique",
"nullsNotDistinct": false,
"columns": [
"tenant_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenants": {
"name": "tenants",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nombre_empresa": {
"name": "nombre_empresa",
"type": "text",
"primaryKey": false,
"notNull": true
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"whatsapp_business": {
"name": "whatsapp_business",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenants_slug_unique": {
"name": "tenants_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.calidad": {
"name": "calidad",
"schema": "public",
"values": [
"basica",
"media",
"premium"
]
},
"public.categoria_material": {
"name": "categoria_material",
"schema": "public",
"values": [
"suelo",
"pared",
"pintura",
"mobiliario"
]
},
"public.lead_estado": {
"name": "lead_estado",
"schema": "public",
"values": [
"nuevo",
"contactado",
"visita_agendada",
"presupuesto_enviado",
"ganado",
"perdido"
]
},
"public.pipeline_stage": {
"name": "pipeline_stage",
"schema": "public",
"values": [
"form_completado",
"fotos_subidas",
"prellamada_enviada",
"llamada_completada",
"render_generado",
"presupuesto_generado",
"whatsapp_entregado"
]
},
"public.tipo_reforma": {
"name": "tipo_reforma",
"schema": "public",
"values": [
"cocina",
"bano",
"salon",
"comedor",
"integral",
"otro"
]
},
"public.unidad_medida": {
"name": "unidad_medida",
"schema": "public",
"values": [
"m2",
"ml",
"ud"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780056789929,
"tag": "0000_motionless_jackpot",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780137082579,
"tag": "0001_bored_preak",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780162638625,
"tag": "0002_overjoyed_the_renegades",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780169328805,
"tag": "0003_youthful_white_queen",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780170597963,
"tag": "0004_even_stranger",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1780237037524,
"tag": "0005_tearful_maverick",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
serverExternalPackages: ['@react-pdf/renderer'],
async rewrites() { async rewrites() {
return [ return [
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html. // Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.

3648
mvp/b2c/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,22 +6,40 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "16.2.6", "next": "16.2.6",
"postcss": "^8.5.15", "postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"tailwindcss": "^4.3.0" "tailwindcss": "^4.3.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"typescript": "^5" "tsx": "^4.22.3",
"typescript": "^5",
"vitest": "^4.1.7"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
mvp/b2c/public/antes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
mvp/b2c/public/despues.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

4
mvp/b2c/public/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2F5C46"/>
<text x="32" y="48" text-anchor="middle" font-family="Georgia, 'Instrument Serif', 'Times New Roman', serif" font-style="italic" font-size="48" fill="#F6F4EF">R</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -0,0 +1,32 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { requireAdmin } from '@/lib/auth/current-user';
import AppNav from '@/components/AppNav';
export const metadata: Metadata = { title: 'Admin · Reformix' };
const ADMIN_LINKS = [
{ href: '/admin', label: 'Resumen', icon: 'resumen' },
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'usuarios' },
{ href: '/admin/planes', label: 'Planes', icon: 'planes' },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin();
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">R</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600">Admin</span>
</Link>
<AppNav links={ADMIN_LINKS} />
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { listTenants, listUsers, listPlans } from '@/db/admin-queries';
export const dynamic = 'force-dynamic';
export default async function AdminHome() {
const [tenants, users, plans] = await Promise.all([listTenants(), listUsers(), listPlans()]);
const cards = [
{ label: 'Reformistas (tenants)', value: tenants.length },
{ label: 'Usuarios', value: users.length },
{ label: 'Planes activos', value: plans.length },
{ label: 'En trial', value: tenants.filter((t) => t.subscriptionStatus === 'trial').length },
];
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-black tracking-tight text-black">Resumen</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((c) => (
<div key={c.label} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="text-3xl font-black text-black">{c.value}</div>
<div className="text-xs text-gray-500 mt-1">{c.label}</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/auth/current-user';
import { assignPlan, setSubscriptionStatus } from '@/db/admin-queries';
import { tenants } from '@/db/schema';
export async function asignarPlan(formData: FormData) {
await requireAdmin();
const tenantId = String(formData.get('tenantId'));
const planId = String(formData.get('planId'));
const status = String(formData.get('status')) as (typeof tenants.subscriptionStatus.enumValues)[number];
await assignPlan(tenantId, planId);
await setSubscriptionStatus(tenantId, status);
revalidatePath('/admin/planes');
}

View File

@@ -0,0 +1,61 @@
import { listPlans, listTenants } from '@/db/admin-queries';
import { asignarPlan } from './actions';
import { formatEuros } from '@/lib/funnel';
export const dynamic = 'force-dynamic';
const ESTADOS = ['trial', 'activo', 'cancelado', 'vencido'] as const;
export default async function PlanesPage() {
const [plans, tenants] = await Promise.all([listPlans(), listTenants()]);
const nombrePlan = new Map(plans.map((p) => [p.id, p.nombre]));
return (
<div className="flex flex-col gap-8">
<h1 className="text-2xl font-black tracking-tight text-black">Planes</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{plans.map((p) => (
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-baseline justify-between">
<h2 className="font-bold text-black">{p.nombre}</h2>
<span className="text-lg font-black text-black">{formatEuros(p.precioMensual)}/mes</span>
</div>
<ul className="mt-3 flex flex-col gap-1 text-xs text-gray-500">
{p.features.map((f) => <li key={f}>· {f}</li>)}
</ul>
</div>
))}
</div>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Reformista</th><th className="px-4 py-3">Plan actual</th>
<th className="px-4 py-3">Estado</th><th className="px-4 py-3">Asignar</th>
</tr></thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium text-black">{t.nombreEmpresa}</td>
<td className="px-4 py-3 text-gray-600">{t.planId ? nombrePlan.get(t.planId) ?? '—' : '—'}</td>
<td className="px-4 py-3 text-gray-600">{t.subscriptionStatus}</td>
<td className="px-4 py-3">
<form action={asignarPlan} className="flex items-center gap-2">
<input type="hidden" name="tenantId" value={t.id} />
<select name="planId" defaultValue={t.planId ?? plans[0]?.id} className="border border-gray-300 rounded-md px-2 py-1 text-xs">
{plans.map((p) => <option key={p.id} value={p.id}>{p.nombre}</option>)}
</select>
<select name="status" defaultValue={t.subscriptionStatus} className="border border-gray-300 rounded-md px-2 py-1 text-xs">
{ESTADOS.map((e) => <option key={e} value={e}>{e}</option>)}
</select>
<button type="submit" className="bg-black text-white rounded-md px-3 py-1 text-xs font-semibold">Guardar</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useActionState } from 'react';
import { crearReformista } from './actions';
export function CrearReformistaForm() {
const [error, action, pending] = useActionState(crearReformista, null);
return (
<form action={action} className="bg-white border border-gray-200 rounded-xl p-5 grid grid-cols-1 md:grid-cols-2 gap-3">
<h2 className="md:col-span-2 font-bold text-black">Crear reformista</h2>
<input name="nombre" placeholder="Nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="empresa" placeholder="Empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
{error && <p className="md:col-span-2 text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="md:col-span-2 bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
{pending ? 'Creando…' : 'Crear reformista'}
</button>
</form>
);
}

View File

@@ -0,0 +1,39 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/auth/current-user';
import { signupSchema, slugify } from '@/lib/validation/signup';
import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries';
import { setUserStatus } from '@/db/admin-queries';
import { hashPassword } from '@/lib/auth/password';
export async function crearReformista(_prev: string | null, formData: FormData): Promise<string | null> {
await requireAdmin();
const parsed = signupSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.';
const data = parsed.data;
if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.';
let slug = slugify(data.empresa);
let n = 1;
while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`;
await createTenantWithOwner({
nombreEmpresa: data.empresa,
slug,
provincia: data.provincia,
email: data.email,
passwordHash: await hashPassword(data.password),
nombre: data.nombre,
});
revalidatePath('/admin/usuarios');
return null;
}
export async function toggleUsuario(formData: FormData) {
await requireAdmin();
const userId = String(formData.get('userId'));
const next = String(formData.get('next')) as 'activo' | 'deshabilitado';
await setUserStatus(userId, next);
revalidatePath('/admin/usuarios');
}

View File

@@ -0,0 +1,45 @@
import { listUsers, listTenants } from '@/db/admin-queries';
import { toggleUsuario } from './actions';
import { CrearReformistaForm } from './CrearReformistaForm';
export const dynamic = 'force-dynamic';
export default async function UsuariosPage() {
const [users, tenants] = await Promise.all([listUsers(), listTenants()]);
const empresaDe = new Map(tenants.map((t) => [t.id, t.nombreEmpresa]));
return (
<div className="flex flex-col gap-8">
<h1 className="text-2xl font-black tracking-tight text-black">Usuarios</h1>
<CrearReformistaForm />
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Email</th><th className="px-4 py-3">Rol</th>
<th className="px-4 py-3">Empresa</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3"></th>
</tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium text-black">{u.email}</td>
<td className="px-4 py-3 text-gray-600">{u.role}</td>
<td className="px-4 py-3 text-gray-600">{u.tenantId ? empresaDe.get(u.tenantId) ?? '—' : '—'}</td>
<td className="px-4 py-3 text-gray-600">{u.status}</td>
<td className="px-4 py-3 text-right">
{u.role !== 'admin' && (
<form action={toggleUsuario}>
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="next" value={u.status === 'activo' ? 'deshabilitado' : 'activo'} />
<button type="submit" className="text-xs font-medium text-gray-500 hover:text-black">
{u.status === 'activo' ? 'Deshabilitar' : 'Habilitar'}
</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -51,7 +51,8 @@
color: var(--color-dark); color: var(--color-dark);
background-color: var(--color-white); background-color: var(--color-white);
line-height: 1.6; line-height: 1.6;
overflow-x: hidden; overflow-x: hidden; /* fallback navegadores antiguos */
overflow-x: clip; /* no crea contenedor de scroll: evita romper position:fixed (barra inferior) */
} }
/* Custom Scrollbar */ /* Custom Scrollbar */

View File

@@ -2,13 +2,14 @@ import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'FlowSync — El SaaS que impulsa tu equipo', title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
description: description:
'Automatiza flujos de trabajo, conecta equipos y escala tu negocio con FlowSync. La plataforma SaaS todo-en-uno para equipos modernos.', 'Sube fotos de tu cocina o baño y recibe un render con el presupuesto orientativo en minutos. Te llamamos en menos de 2.',
keywords: ['SaaS', 'productividad', 'automatización', 'equipos', 'gestión de proyectos'], keywords: ['reforma', 'presupuesto reforma', 'render reforma', 'cocina', 'baño', 'reformistas'],
icons: { icon: '/icon.svg' },
openGraph: { openGraph: {
title: 'FlowSync — El SaaS que impulsa tu equipo', title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
description: 'Automatiza flujos de trabajo y escala tu negocio con FlowSync.', description: 'Render y presupuesto orientativo de tu reforma en minutos, por WhatsApp.',
type: 'website', type: 'website',
}, },
}; };

View File

@@ -0,0 +1,19 @@
'use server';
import { redirect } from 'next/navigation';
import { getUserByEmail } from '@/db/auth-queries';
import { verifyPassword } from '@/lib/auth/password';
import { createSession } from '@/lib/auth/session';
export async function login(_prev: string | null, formData: FormData): Promise<string | null> {
const email = String(formData.get('email') ?? '').trim().toLowerCase();
const password = String(formData.get('password') ?? '');
if (!email || !password) return 'Introduce email y contraseña.';
const user = await getUserByEmail(email);
if (!user || user.status !== 'activo') return 'Credenciales incorrectas.';
if (!(await verifyPassword(password, user.passwordHash))) return 'Credenciales incorrectas.';
await createSession(user.id);
redirect(user.role === 'admin' ? '/admin' : '/panel');
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useActionState } from 'react';
import { login } from './actions';
export default function LoginPage() {
const [error, formAction, pending] = useActionState(login, null);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Email</span>
<input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Contraseña</span>
<input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
{pending ? 'Entrando…' : 'Entrar'}
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation';
import { destroySession } from '@/lib/auth/session';
export async function POST() {
await destroySession();
redirect('/login');
}

View File

@@ -2,7 +2,7 @@ import Navbar from '@/components/Navbar/Navbar';
import Hero from '@/components/Hero/Hero'; import Hero from '@/components/Hero/Hero';
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider'; import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
import Features from '@/components/Features/Features'; import Features from '@/components/Features/Features';
import Pricing from '@/components/Pricing/Pricing'; import Testimonials from '@/components/Testimonials/Testimonials';
import ContactForm from '@/components/ContactForm/ContactForm'; import ContactForm from '@/components/ContactForm/ContactForm';
import Footer from '@/components/Footer/Footer'; import Footer from '@/components/Footer/Footer';
@@ -14,7 +14,7 @@ export default function Home() {
<Hero /> <Hero />
<ReformaSlider /> <ReformaSlider />
<Features /> <Features />
<Pricing /> <Testimonials />
</main> </main>
<Footer /> <Footer />
</> </>

View File

@@ -0,0 +1,369 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
PIPELINE_ORDER,
TIPO_LABEL,
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
{children}
</section>
);
}
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getLead(id);
if (!data) notFound();
const { lead, fotos, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
return (
<div className="flex flex-col gap-6">
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
Volver a leads
</Link>
{/* Cabecera + estado */}
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">{lead.nombre}</h1>
<p className="text-sm text-gray-500">
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
</p>
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Presupuesto estimado</div>
<div className="text-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
</div>
</div>
<EstadoControl
leadId={lead.id}
estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado}
/>
</div>
{/* Timeline del funnel */}
<Section title="Progreso en el funnel">
<ol className="flex flex-col gap-2">
{PIPELINE_ORDER.map((stage) => {
const reached = reachedStages.has(stage);
const isCurrent = stage === lead.pipelineStage;
return (
<li key={stage} className="flex items-center gap-3 text-sm">
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
reached ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
<span className={reached ? 'text-black font-medium' : 'text-gray-400'}>
{PIPELINE_LABEL[stage]}
</span>
{isCurrent && (
<span className="ml-auto text-xs text-amber-600 font-medium">
{PIPELINE_NEXT[stage]}
</span>
)}
</li>
);
})}
</ol>
</Section>
<div className="grid md:grid-cols-2 gap-6">
{/* 1. Datos personales */}
<Section title="Datos personales">
<dl className="text-sm flex flex-col gap-2">
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Teléfono</dt>
<dd className="text-black font-medium">{lead.telefono}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Email</dt>
<dd className="text-black font-medium break-all">{lead.email}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Provincia</dt>
<dd className="text-black font-medium">{lead.provincia ?? '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Consentimientos</dt>
<dd className="text-black font-medium">
{lead.consentPrivacidad ? 'Privacidad ✓' : 'Privacidad ✗'} ·{' '}
{lead.consentContratacion ? 'Contratación ✓' : 'Contratación ✗'}
</dd>
</div>
</dl>
</Section>
{/* 4. Render */}
<Section title="Render generado">
{lead.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
) : (
<p className="text-sm text-gray-400">Aún no generado.</p>
)}
</Section>
{/* Preferencias detectadas */}
<Section title="Preferencias detectadas">
{prefs ? (
<div className="flex flex-col gap-3 text-sm">
<p className="text-gray-700">{prefs.resumen}</p>
{prefs.estiloRender.length > 0 && (
<div className="flex flex-wrap gap-2">
{prefs.estiloRender.map((e) => (
<span key={e} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 text-xs">
{e}
</span>
))}
</div>
)}
{prefs.elementos.length > 0 && (
<ul className="flex flex-col gap-1">
{prefs.elementos.map((el) => (
<li key={el.key} className="flex justify-between">
<span className="text-gray-600">{el.label}</span>
<span className="text-black font-medium">{formatEuros(el.importe)}</span>
</li>
))}
</ul>
)}
{prefs.ajustes.length > 0 && (
<ul className="text-xs text-gray-500 flex flex-col gap-1">
{prefs.ajustes.map((a, i) => (
<li key={i}>
<span className="font-medium text-gray-700">{a.label}</span> {a.motivo}
</li>
))}
</ul>
)}
<div className="text-xs text-gray-400">
Confianza de la extracción: {prefs.confianza}
{prefs.camposFaltantes.length > 0 && ` · faltan: ${prefs.camposFaltantes.join(', ')}`}
</div>
</div>
) : (
<p className="text-sm text-gray-400">Sin preferencias procesadas aún.</p>
)}
</Section>
{/* 2. Transcripción */}
<Section title="Transcripción de la llamada">
{lead.transcripcion ? (
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap max-h-64 overflow-auto">
{lead.transcripcion}
</p>
) : (
<p className="text-sm text-gray-400">Aún no hay llamada.</p>
)}
</Section>
{/* 3. JSON de entidades */}
<Section title="Entidades extraídas (JSON)">
{lead.entidades ? (
<pre className="text-xs bg-gray-900 text-gray-100 rounded-lg p-4 overflow-auto max-h-64">
{JSON.stringify(lead.entidades, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-400">Sin entidades aún.</p>
)}
</Section>
{/* 5. Audio */}
<Section title="Audio de la llamada">
{lead.audioUrl ? (
<audio controls src={lead.audioUrl} className="w-full">
Tu navegador no soporta audio.
</audio>
) : (
<p className="text-sm text-gray-400">Sin grabación.</p>
)}
</Section>
{/* 6. PDF */}
<Section title="Presupuesto (PDF)">
{desglose ? (
<div className="flex flex-wrap items-center gap-3">
<a
href={`/panel/${lead.id}/presupuesto?download=1`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Descargar PDF
</a>
<a
href={`/panel/${lead.id}/presupuesto`}
target="_blank"
rel="noopener"
className="text-sm font-medium text-gray-500 hover:text-black"
>
Ver en el navegador
</a>
</div>
) : (
<p className="text-sm text-gray-400">
Recalcula el presupuesto desde el catálogo para generarlo.
</p>
)}
</Section>
</div>
{/* Fotos subidas */}
{fotos.length > 0 && (
<Section title="Fotos subidas por el cliente">
<div className="flex flex-wrap gap-3">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
))}
</div>
</Section>
)}
{/* Precisión (si ganado) */}
{precision && (
<Section title="Precisión del presupuesto">
<div className="flex flex-wrap gap-8 text-sm">
<div>
<div className="text-gray-400 text-xs">Estimado</div>
<div className="text-black font-bold text-lg">{formatEuros(precision.estimated)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Final firmado</div>
<div className="text-black font-bold text-lg">{formatEuros(precision.final)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Desviación</div>
<div
className={`font-bold text-lg ${
Math.abs(Number(precision.deltaPct)) <= 15 ? 'text-green-600' : 'text-amber-600'
}`}
>
{Number(precision.deltaPct) > 0 ? '+' : ''}
{precision.deltaPct}%
</div>
</div>
</div>
</Section>
)}
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<div className="flex flex-wrap items-center gap-3">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Recalcular desde el catálogo
</button>
</form>
{desglose && (
<a
href={`/panel/${lead.id}/presupuesto`}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm font-semibold text-gray-700 hover:border-gray-500"
>
Revisar PDF
</a>
)}
</div>
{desglose ? (
<div className="flex flex-col gap-4 mt-2">
{yaEnviado && (
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-3 py-2">
Presupuesto enviado al cliente por WhatsApp
</div>
)}
{/* Conceptos editables + subtotal/factor zona/total */}
<ConceptosEditor
leadId={lead.id}
partidas={desglose.partidas}
factorZona={desglose.factorZona}
bloqueado={yaEnviado}
/>
{/* Rango */}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Rango orientativo</span>
<span className="text-black font-medium">
{formatEuros(desglose.rango.min)} {formatEuros(desglose.rango.max)}
</span>
</div>
{/* Confianza */}
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Confianza del cálculo</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
desglose.confianza === 'alta'
? 'bg-green-100 text-green-700'
: desglose.confianza === 'media'
? 'bg-amber-100 text-amber-700'
: 'bg-red-100 text-red-700'
}`}
>
{desglose.confianza}
</span>
</div>
{/* Avisos */}
{desglose.avisos.length > 0 && (
<ul className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3 flex flex-col gap-1">
{desglose.avisos.map((aviso, i) => (
<li key={i}> {aviso}</li>
))}
</ul>
)}
{/* Disclaimer RF-B-09 */}
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
{/* Enviar al cliente (envío simulado: registra la entrega por WhatsApp) */}
{!yaEnviado && (
<form action={enviarPresupuesto.bind(null, lead.id)} className="border-t border-gray-200 pt-4">
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-semibold w-fit hover:bg-green-700"
>
Enviar al cliente por WhatsApp
</button>
</form>
)}
</div>
) : (
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
)}
</Section>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { notFound } from 'next/navigation';
import { renderToBuffer } from '@react-pdf/renderer';
import { getLead } from '@/db/queries';
import { getTenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
import type { BudgetResult } from '@/budget/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const data = await getLead(id);
if (!data) notFound();
const descargar = new URL(req.url).searchParams.get('download') === '1';
const { lead } = data;
const empresa = await getTenantPerfil();
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa,
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
reforma: {
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
fecha: lead.createdAt,
},
desglose,
})
);
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="presupuesto-${slug || lead.id}.pdf"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -0,0 +1,172 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import { applyConceptoEdits } from '@/budget/edit';
import type { BudgetInputs, BudgetResult, PartidaResult } from '@/budget/types';
type Estado = (typeof leads.estado.enumValues)[number];
export async function cambiarEstado(leadId: string, estado: Estado) {
const tenantId = await getTenantId();
const [updated] = await db
.update(leads)
.set({ estado, updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.returning();
if (!updated) throw new Error('Lead no encontrado.');
await db.insert(leadEstadoHistory).values({ leadId, estado });
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function marcarGanado(leadId: string, precioFinalEuros: number) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
if (lead.presupuestoEstimado == null) {
throw new Error('El lead no tiene presupuesto estimado, no se puede calcular la precisión.');
}
const finalCents = Math.round(precioFinalEuros * 100);
const deltaPct = ((finalCents - lead.presupuestoEstimado) / lead.presupuestoEstimado) * 100;
await db
.update(leads)
.set({ estado: 'ganado', updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'ganado' });
await db.insert(precisionHistory).values({
leadId,
estimated: lead.presupuestoEstimado,
final: finalCents,
deltaPct: deltaPct.toFixed(2),
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function editarConceptos(leadId: string, formData: FormData) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const snapshot = lead.desgloseSnapshot as ({ result: BudgetResult } & Record<string, unknown>) | null;
if (!snapshot?.result) {
throw new Error('El lead no tiene presupuesto que editar.');
}
const keys = formData.getAll('key').map(String);
const labels = formData.getAll('label').map(String);
const importes = formData.getAll('importeEuros').map((v) => Number(v));
const partidas: PartidaResult[] = labels.map((label, i) => {
const euros = importes[i];
const importe = Number.isFinite(euros) ? Math.round(euros * 100) : 0;
return { key: keys[i] || `custom-${i}`, label, importe };
});
const edited = applyConceptoEdits(snapshot.result, partidas);
await db
.update(leads)
.set({
presupuestoEstimado: edited.total,
desgloseSnapshot: { ...snapshot, result: edited },
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function enviarPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
if (lead.presupuestoEstimado == null) {
throw new Error('El lead no tiene presupuesto que enviar.');
}
await db
.update(leads)
.set({ estado: 'presupuesto_enviado', pipelineStage: 'whatsapp_entregado', updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'presupuesto_enviado' });
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'whatsapp_entregado',
metadata: { via: 'whatsapp', simulado: true, total: lead.presupuestoEstimado },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function recalcularPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
const inputs: BudgetInputs = {
tipoReforma: lead.tipoReforma ?? 'otro',
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
calidadGlobal: lead.calidadGlobal ?? 'media',
estructural: lead.estructural,
provincia: lead.provincia ?? null,
materialSelections: (lead.materialSelections as Record<string, string>) ?? {},
};
const result = computeBudget(inputs, config, catalog);
await db
.update(leads)
.set({
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: lead.pipelineStage, result },
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'presupuesto_generado',
metadata: { total: result.total, confianza: result.confianza },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}

View File

@@ -0,0 +1,66 @@
'use server';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
const LOGO_MAX_BYTES = 500_000;
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
function limpiar(raw: FormDataEntryValue | null): string | null {
const s = String(raw ?? '').trim();
return s.length > 0 ? s : null;
}
export async function actualizarEmpresa(formData: FormData) {
const tenantId = await getTenantId();
const nombreEmpresa = limpiar(formData.get('nombreEmpresa'));
if (!nombreEmpresa) {
throw new Error('El nombre de la empresa es obligatorio.');
}
await db
.update(tenants)
.set({
nombreEmpresa,
cif: limpiar(formData.get('cif')),
direccion: limpiar(formData.get('direccion')),
provincia: limpiar(formData.get('provincia')),
telefono: limpiar(formData.get('telefono')),
email: limpiar(formData.get('email')),
web: limpiar(formData.get('web')),
})
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}
export type LogoResult = { ok: boolean; error?: string };
export async function subirLogo(_prev: LogoResult | null, formData: FormData): Promise<LogoResult> {
const tenantId = await getTenantId();
const file = formData.get('logo');
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'Selecciona un archivo de imagen.' };
}
if (!LOGO_TIPOS.includes(file.type)) {
return { ok: false, error: 'Formato no válido. Usa PNG, JPG, WEBP o SVG.' };
}
if (file.size > LOGO_MAX_BYTES) {
return { ok: false, error: 'El logo no puede superar los 500 KB.' };
}
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
await db.update(tenants).set({ logoUrl: dataUri }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
return { ok: true };
}
export async function quitarLogo() {
const tenantId = await getTenantId();
await db.update(tenants).set({ logoUrl: null }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}

View File

@@ -0,0 +1,93 @@
import { getTenantPerfil } from '@/db/tenant-queries';
import { actualizarEmpresa } from './actions';
import LogoUploader from '@/components/panel/LogoUploader';
export const dynamic = 'force-dynamic';
export default async function EmpresaPage() {
const perfil = await getTenantPerfil();
return (
<div className="space-y-10 max-w-2xl">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
<p className="text-sm text-gray-500 mt-1">
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
cliente. Manténlos al día.
</p>
</div>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Logo</h2>
<LogoUploader logoUrl={perfil.logoUrl} />
</section>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Identidad</h2>
<form action={actualizarEmpresa} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Nombre de la empresa *</span>
<input
name="nombreEmpresa"
required
defaultValue={perfil.nombreEmpresa}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">CIF / NIF</span>
<input
name="cif"
defaultValue={perfil.cif ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Provincia</span>
<input
name="provincia"
defaultValue={perfil.provincia ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Dirección</span>
<input
name="direccion"
defaultValue={perfil.direccion ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Teléfono</span>
<input
name="telefono"
defaultValue={perfil.telefono ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Email</span>
<input
name="email"
type="email"
defaultValue={perfil.email ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Web</span>
<input
name="web"
defaultValue={perfil.web ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar datos
</button>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { requireUser } from '@/lib/auth/current-user';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { eq } from 'drizzle-orm';
import AppNav from '@/components/AppNav';
const PANEL_LINKS = [
{ href: '/panel', label: 'Leads', icon: 'leads' },
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
] as const;
export const metadata: Metadata = {
title: 'Panel · Reformix',
description: 'Panel de leads del reformista',
};
export default async function PanelLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
const [tenant] = user.tenantId
? await db.select().from(tenants).where(eq(tenants.id, user.tenantId)).limit(1)
: [];
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
R
</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
{nombreEmpresa}
</span>
</Link>
<AppNav links={PANEL_LINKS} />
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,177 @@
import Link from 'next/link';
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
import {
ESTADOS,
ESTADO_BADGE,
ESTADO_LABEL,
PIPELINE_LABEL,
PIPELINE_NEXT,
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { getCurrentTenantId } from '@/lib/auth/current-user';
import { db } from '@/db';
import { tenants, plans } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { formatPlanBadge } from '@/lib/billing/plan';
import { STRIPE_ENABLED } from '@/lib/billing/stripe';
export const dynamic = 'force-dynamic';
const FILTROS: { value: LeadFiltro; label: string }[] = [
{ value: 'todos', label: 'Todos' },
...ESTADOS.map((e) => ({ value: e as LeadFiltro, label: ESTADO_LABEL[e] })),
];
export default async function PanelPage({
searchParams,
}: {
searchParams: Promise<{ estado?: string }>;
}) {
const { estado } = await searchParams;
const filtro: LeadFiltro = (FILTROS.find((f) => f.value === estado)?.value ?? 'todos') as LeadFiltro;
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
const tenantId = await getCurrentTenantId();
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
const [plan] = tenant?.planId
? await db.select().from(plans).where(eq(plans.id, tenant.planId)).limit(1)
: [];
const badge = formatPlanBadge(plan?.nombre ?? null, tenant?.subscriptionStatus ?? 'trial', tenant?.trialEndsAt ?? null);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">Leads</h1>
<p className="text-sm text-gray-500">
{resumen.total} leads en total · {resumen.porEstado['nuevo'] ?? 0} sin contactar
</p>
</div>
<div className="flex items-center justify-between bg-white border border-gray-200 rounded-xl px-4 py-3">
<span className="text-sm font-medium text-gray-700">{badge}</span>
<button
type="button"
disabled={!STRIPE_ENABLED}
title="Próximamente"
className="text-xs font-semibold text-gray-400 cursor-not-allowed"
>
Gestionar pago
</button>
</div>
{/* Filtros por estado */}
<div className="flex flex-wrap gap-2">
{FILTROS.map((f) => {
const active = f.value === filtro;
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
return (
<Link
key={f.value}
href={f.value === 'todos' ? '/panel' : `/panel?estado=${f.value}`}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-black text-white border-black'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
{f.label} <span className={active ? 'text-gray-300' : 'text-gray-400'}>{count}</span>
</Link>
);
})}
</div>
{/* Tabla (desktop) */}
<div className="hidden md:block bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3 font-semibold">Render</th>
<th className="px-4 py-3 font-semibold">Cliente</th>
<th className="px-4 py-3 font-semibold">Fecha</th>
<th className="px-4 py-3 font-semibold">Estado</th>
<th className="px-4 py-3 font-semibold">Presupuesto</th>
<th className="px-4 py-3 font-semibold">Siguiente paso</th>
</tr>
</thead>
<tbody>
{leads.map((l) => (
<tr key={l.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="block w-16 h-12 rounded-md overflow-hidden bg-gray-100">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="flex w-full h-full items-center justify-center text-[10px] text-gray-400">
sin render
</span>
)}
</Link>
</td>
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="font-semibold text-black hover:underline">
{l.nombre}
</Link>
<div className="text-gray-500">{l.telefono}</div>
</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatFecha(l.createdAt)}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</td>
<td className="px-4 py-3 font-semibold text-black whitespace-nowrap">
{formatEuros(l.presupuestoEstimado)}
</td>
<td className="px-4 py-3 text-gray-500">
<div className="text-xs text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</div>
{PIPELINE_NEXT[l.pipelineStage]}
</td>
</tr>
))}
</tbody>
</table>
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
{/* Cards (mobile) */}
<div className="md:hidden flex flex-col gap-3">
{leads.map((l) => (
<Link
key={l.id}
href={`/panel/${l.id}`}
className="bg-white border border-gray-200 rounded-xl p-4 flex gap-3"
>
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-100 shrink-0">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : null}
</div>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-black truncate">{l.nombre}</span>
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</div>
<div className="text-sm text-gray-500">{l.telefono}</div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-black">{formatEuros(l.presupuestoEstimado)}</span>
<span className="text-gray-400">{formatFecha(l.createdAt)}</span>
</div>
<div className="text-xs text-gray-400">{PIPELINE_NEXT[l.pipelineStage]}</div>
</div>
</Link>
))}
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { catalogItems, pricingConfig, tenants } from '@/db/schema';
import { getTenantId, type EnvioMode } from '@/db/pricing-queries';
import { parseCatalogCsv } from '@/budget/csv';
// Valida un importe en euros del formulario y lo convierte a céntimos.
// Lanza un error en español si el valor no es un número finito >= 0.
function eurosToCents(raw: FormDataEntryValue | null, campo: string): number {
const euros = Number(raw);
if (!Number.isFinite(euros) || euros < 0) {
throw new Error(`El valor de "${campo}" debe ser un número mayor o igual que 0.`);
}
return Math.round(euros * 100);
}
function parsePositive(raw: FormDataEntryValue | null, campo: string): number {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(`El valor de "${campo}" debe ser un número mayor que 0.`);
}
return n;
}
export async function crearMaterial(formData: FormData) {
const tenantId = await getTenantId();
await db.insert(catalogItems).values({
tenantId,
categoria: formData.get('categoria') as 'suelo' | 'pared' | 'pintura' | 'mobiliario',
nombre: String(formData.get('nombre') ?? ''),
calidad: formData.get('calidad') as 'basica' | 'media' | 'premium',
precioUnit: eurosToCents(formData.get('precioEuros'), 'precio'),
unidad: formData.get('unidad') as 'm2' | 'ml' | 'ud',
descriptorRender: String(formData.get('descriptorRender') ?? ''),
esDefault: formData.get('esDefault') === 'on',
sku: String(formData.get('sku') ?? ''),
});
revalidatePath('/panel/precios');
}
export async function actualizarPrecio(formData: FormData) {
const tenantId = await getTenantId();
const id = String(formData.get('id') ?? '');
await db
.update(catalogItems)
.set({ precioUnit: eurosToCents(formData.get('precioEuros'), 'precio') })
.where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId)));
revalidatePath('/panel/precios');
}
export async function borrarMaterial(formData: FormData) {
const tenantId = await getTenantId();
const id = String(formData.get('id') ?? '');
await db.delete(catalogItems).where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId)));
revalidatePath('/panel/precios');
}
export async function actualizarConfig(formData: FormData) {
const tenantId = await getTenantId();
await db
.update(pricingConfig)
.set({
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
manoObra: {
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
},
updatedAt: new Date(),
})
.where(eq(pricingConfig.tenantId, tenantId));
revalidatePath('/panel/precios');
}
export async function actualizarEnvio(formData: FormData) {
const tenantId = await getTenantId();
const modo = formData.get('modo');
if (modo !== 'automatico' && modo !== 'revision') {
throw new Error('Modo de envío no válido.');
}
await db
.update(tenants)
.set({ envioPresupuesto: modo as EnvioMode })
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/precios');
}
export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] };
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {
const tenantId = await getTenantId();
const csv = String(formData.get('csv') ?? '');
const { rows, errors } = parseCatalogCsv(csv);
if (errors.length > 0) return { ok: false, inserted: 0, errors };
for (const r of rows) {
await db
.insert(catalogItems)
.values({ tenantId, ...r })
.onConflictDoUpdate({
target: [catalogItems.tenantId, catalogItems.sku],
set: {
categoria: r.categoria,
nombre: r.nombre,
calidad: r.calidad,
precioUnit: r.precioUnit,
unidad: r.unidad,
descriptorRender: r.descriptorRender,
},
});
}
revalidatePath('/panel/precios');
return { ok: true, inserted: rows.length, errors: [] };
}

View File

@@ -0,0 +1,244 @@
import { getPricingConfig, getCatalog, getEnvioMode } from '@/db/pricing-queries';
import {
crearMaterial,
actualizarPrecio,
borrarMaterial,
actualizarConfig,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
export const dynamic = 'force-dynamic';
const CATEGORIAS = ['suelo', 'pared', 'pintura', 'mobiliario'] as const;
const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
suelo: 'Suelos',
pared: 'Paredes / alicatado',
pintura: 'Pinturas',
mobiliario: 'Mobiliario',
};
export default async function PreciosPage() {
const [config, catalog, envioMode] = await Promise.all([
getPricingConfig(),
getCatalog(),
getEnvioMode(),
]);
return (
<div className="space-y-10">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Tabla de precios</h1>
<p className="text-sm text-gray-500 mt-1">
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
partir de estos valores y las medidas del lead.
</p>
</div>
{/* Envío de presupuestos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Envío de presupuestos</h2>
<p className="text-sm text-gray-500 mb-4">
Decide si el presupuesto se entrega al cliente automáticamente al final del funnel o si
quieres revisarlo y editar los conceptos antes de enviarlo.
</p>
<form action={actualizarEnvio} className="flex flex-col gap-3">
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="radio"
name="modo"
value="automatico"
defaultChecked={envioMode === 'automatico'}
className="mt-1"
/>
<span>
<span className="block font-medium text-black">Envío automático</span>
<span className="block text-gray-500">
El cliente recibe el presupuesto por WhatsApp en cuanto el funnel lo genera.
</span>
</span>
</label>
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="radio"
name="modo"
value="revision"
defaultChecked={envioMode === 'revision'}
className="mt-1"
/>
<span>
<span className="block font-medium text-black">Revisar antes de enviar</span>
<span className="block text-gray-500">
El funnel se detiene en cada lead para que revises los conceptos y pulses enviar.
</span>
</span>
</label>
<button className="self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar preferencia
</button>
</form>
</section>
{/* Config general */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Configuración general</h2>
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
<label className="text-sm">
<span className="block text-xs text-gray-500 mb-1">Altura techo (m)</span>
<input
name="alturaTechoDefault"
type="number"
step="0.1"
defaultValue={config.alturaTechoDefault}
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
{(
[
['demolicion', 'Demolición'],
['fontaneria', 'Fontanería'],
['electricidad', 'Electricidad'],
['mano_de_obra', 'Mano de obra'],
] as const
).map(([k, etiqueta]) => (
<label key={k} className="text-sm">
<span className="block text-xs text-gray-500 mb-1">{etiqueta} (/m²)</span>
<input
name={`mo_${k}`}
type="number"
step="0.01"
defaultValue={(config.manoObra[k] ?? 0) / 100}
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
))}
<button className="col-span-2 md:col-span-5 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar configuración
</button>
</form>
</section>
{/* Catálogo por categoría */}
{CATEGORIAS.map((categoria) => {
const items = catalog.filter((c) => c.categoria === categoria);
return (
<section key={categoria} className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">{CATEGORIA_LABEL[categoria]}</h2>
<div className="space-y-2">
{items.length === 0 && <p className="text-sm text-gray-400">Sin materiales.</p>}
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 border-b border-gray-100 pb-3 text-sm sm:flex-row sm:items-center sm:gap-3 sm:pb-2"
>
<div className="min-w-0 sm:flex-1">
<div className="font-medium text-black">{item.nombre}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
<span className="capitalize">{item.calidad}</span>
<span className="text-gray-300">·</span>
<span>{item.unidad}</span>
{item.esDefault && (
<span className="bg-green-100 text-green-700 rounded px-1.5 py-0.5">default</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<form action={actualizarPrecio} className="flex items-center gap-2">
<input type="hidden" name="id" value={item.id} />
<div className="relative">
<input
name="precioEuros"
type="number"
step="0.01"
defaultValue={item.precioUnit / 100}
className="w-28 border border-gray-300 rounded-lg pl-3 pr-7 py-1.5 text-right"
aria-label={`Precio de ${item.nombre}`}
/>
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400">
</span>
</div>
<button className="text-xs font-medium text-blue-600 hover:underline">Guardar</button>
</form>
<form action={borrarMaterial}>
<input type="hidden" name="id" value={item.id} />
<button className="text-xs text-red-500 hover:underline">Borrar</button>
</form>
</div>
</div>
))}
</div>
<form
action={crearMaterial}
className="mt-5 grid grid-cols-2 gap-2 border-t border-gray-100 pt-4 text-sm sm:flex sm:flex-wrap sm:items-end sm:border-t-0 sm:pt-0"
>
<input type="hidden" name="categoria" value={categoria} />
<input
name="nombre"
placeholder="Nombre"
required
className="col-span-2 border border-gray-300 rounded-lg px-2 py-1.5 sm:col-auto"
/>
<select name="calidad" className="border border-gray-300 rounded-lg px-2 py-1.5">
<option value="basica">Básica</option>
<option value="media">Media</option>
<option value="premium">Premium</option>
</select>
<input
name="precioEuros"
type="number"
step="0.01"
placeholder="Precio €"
required
className="border border-gray-300 rounded-lg px-2 py-1.5 sm:w-24"
/>
<select name="unidad" className="border border-gray-300 rounded-lg px-2 py-1.5">
<option value="m2">m²</option>
<option value="ml">ml</option>
<option value="ud">ud</option>
</select>
<input
name="sku"
placeholder="SKU"
required
className="border border-gray-300 rounded-lg px-2 py-1.5 sm:w-28"
/>
<input
name="descriptorRender"
placeholder="Descriptor render (opcional)"
className="col-span-2 border border-gray-300 rounded-lg px-2 py-1.5 sm:col-auto sm:flex-1 sm:min-w-40"
/>
<label className="col-span-2 flex items-center gap-2 text-gray-500 sm:col-auto">
<input type="checkbox" name="esDefault" /> Marcar como default
</label>
<button className="col-span-2 bg-black text-white rounded-lg px-3 py-2 font-medium sm:col-auto sm:py-1.5">
Añadir
</button>
</form>
</section>
);
})}
{/* Import CSV */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
<p className="text-xs text-gray-500 mb-3">
Cabecera: <code className="break-all">categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
precio en euros. Actualiza por SKU.
</p>
<form action={importarCatalogoCsv as unknown as (fd: FormData) => void}>
<textarea
name="csv"
rows={5}
placeholder="categoria,nombre,calidad,precio,unidad,descriptor_render,sku"
className="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-xs"
/>
<button className="mt-2 bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Importar
</button>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use server';
import { redirect } from 'next/navigation';
import { signupSchema, slugify } from '@/lib/validation/signup';
import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries';
import { hashPassword } from '@/lib/auth/password';
import { createSession } from '@/lib/auth/session';
export async function signup(_prev: string | null, formData: FormData): Promise<string | null> {
const parsed = signupSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.';
const data = parsed.data;
if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.';
let slug = slugify(data.empresa);
let n = 1;
while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`;
const passwordHash = await hashPassword(data.password);
const { user } = await createTenantWithOwner({
nombreEmpresa: data.empresa,
slug,
provincia: data.provincia,
email: data.email,
passwordHash,
nombre: data.nombre,
});
await createSession(user.id);
redirect('/panel');
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useActionState } from 'react';
import { signup } from './actions';
export default function SignupPage() {
const [error, formAction, pending] = useActionState(signup, null);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6 py-12">
<form action={formAction} className="w-full max-w-md bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
<h1 className="text-xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
<p className="text-sm text-gray-500">Sin tarjeta. Configura tu catálogo y recibe leads.</p>
<input name="nombre" placeholder="Tu nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="empresa" placeholder="Nombre de tu empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<label className="flex items-center gap-2 text-xs text-gray-500">
<input name="optInMarketing" type="checkbox" /> Quiero recibir novedades de Reformix
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
{pending ? 'Creando cuenta…' : 'Crear cuenta'}
</button>
<a href="/login" className="text-xs text-gray-400 text-center hover:text-black">Ya tengo cuenta</a>
</form>
</main>
);
}

View File

@@ -0,0 +1,133 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { PIPELINE_ORDER, PIPELINE_LABEL, TIPO_LABEL, formatEuros } from '@/lib/funnel';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
export default async function EstadoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, eventos } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const entregado = lead.pipelineStage === 'whatsapp_entregado';
const tipo = lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'tu reforma';
return (
<div className="flex flex-col gap-6">
{/* Cabecera de éxito */}
<div className="flex flex-col items-center text-center gap-3">
<div className="w-14 h-14 bg-black text-white rounded-full flex items-center justify-center">
<svg width="26" height="26" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M6 16l7 7L26 9"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h1 className="text-2xl font-black tracking-tight text-black">
¡Tu presupuesto está listo, {lead.nombre.split(' ')[0]}!
</h1>
<p className="text-sm text-gray-500 leading-relaxed max-w-md">
{entregado
? 'Te lo hemos enviado por WhatsApp. Aquí tienes un adelanto del render y el presupuesto orientativo de tu reforma.'
: 'Estamos preparando tu render y presupuesto. En breve lo recibirás por WhatsApp.'}
</p>
</div>
{/* Render */}
{lead.renderUrl && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-3 border-b border-gray-100">
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">
Render de {tipo.toLowerCase()}
</h2>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={lead.renderUrl} alt="Render de tu reforma" className="w-full object-cover" />
</div>
)}
{/* Presupuesto */}
{desglose ? (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-end justify-between gap-4">
<div>
<div className="text-xs text-gray-400 uppercase tracking-wide">
Presupuesto orientativo
</div>
<div className="text-3xl font-black text-black">{formatEuros(desglose.total)}</div>
</div>
<div className="text-right text-sm text-gray-500">
<div className="text-xs text-gray-400">Rango estimado</div>
{formatEuros(desglose.rango.min)} {formatEuros(desglose.rango.max)}
</div>
</div>
<ul className="flex flex-col divide-y divide-gray-100 text-sm">
{desglose.partidas.map((p) => (
<li key={p.key} className="flex justify-between py-2">
<span className="text-gray-600">{p.label}</span>
<span className="text-black font-medium">{formatEuros(p.importe)}</span>
</li>
))}
</ul>
{desglose.avisos.length > 0 && (
<ul className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3 flex flex-col gap-1">
{desglose.avisos.map((aviso, i) => (
<li key={i}> {aviso}</li>
))}
</ul>
)}
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm text-center text-sm text-gray-500">
Calculando tu presupuesto
</div>
)}
{/* Progreso del pipeline */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400 mb-3">
Estado de tu solicitud
</h2>
<ol className="flex flex-col gap-2">
{PIPELINE_ORDER.map((stage) => {
const reached = reachedStages.has(stage);
return (
<li key={stage} className="flex items-center gap-3 text-sm">
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
reached ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
<span className={reached ? 'text-black font-medium' : 'text-gray-400'}>
{PIPELINE_LABEL[stage]}
</span>
</li>
);
})}
</ol>
</div>
{entregado && (
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-4 py-3 text-center">
Presupuesto enviado a tu WhatsApp
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { guardarDetallesYFotos } from '../../actions';
import FotosUploader from '@/components/funnel/FotosUploader';
export const dynamic = 'force-dynamic';
export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead } = data;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Paso 2 de 2
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Sube unas fotos del espacio y dinos qué tienes en mente. Con eso preparamos tu render y un
presupuesto orientativo en menos de un minuto.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<FotosUploader action={guardarDetallesYFotos.bind(null, id)} />
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadFotos, leadPipelineEventos } from '@/db/schema';
import { getDemoTenantId } from '@/lib/funnel/public-queries';
import { procesarLead } from '@/lib/funnel/orchestrator';
const MAX_FOTOS = 4;
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
const crearLeadSchema = z.object({
nombre: z.string().trim().min(2, 'El nombre es obligatorio'),
email: z.string().trim().email('Introduce un email válido'),
telefono: z
.string()
.trim()
.regex(/^[+\d\s\-().]{7,20}$/, 'Introduce un teléfono válido'),
consentPrivacidad: z.boolean(),
consentContratacion: z.boolean(),
});
export type CrearLeadInput = z.input<typeof crearLeadSchema>;
export type CrearLeadResult =
| { ok: true; leadId: string }
| { ok: false; error: string };
export async function crearLead(input: CrearLeadInput): Promise<CrearLeadResult> {
const parsed = crearLeadSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' };
}
const data = parsed.data;
// RF-LEG-01: los dos consentimientos son obligatorios para iniciar el funnel.
if (!data.consentPrivacidad || !data.consentContratacion) {
return { ok: false, error: 'Debes aceptar la política de privacidad y las condiciones.' };
}
const tenantId = await getDemoTenantId();
const [lead] = await db
.insert(leads)
.values({
tenantId,
nombre: data.nombre,
email: data.email,
telefono: data.telefono,
consentPrivacidad: data.consentPrivacidad,
consentContratacion: data.consentContratacion,
pipelineStage: 'form_completado',
estado: 'nuevo',
})
.returning({ id: leads.id });
await db.insert(leadPipelineEventos).values({
leadId: lead.id,
stage: 'form_completado',
metadata: { origen: 'landing' },
});
return { ok: true, leadId: lead.id };
}
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
const CALIDADES = ['basica', 'media', 'premium'] as const;
async function fileToDataUri(file: File): Promise<string | null> {
if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null;
if (!file.type.startsWith('image/')) return null;
const buffer = Buffer.from(await file.arrayBuffer());
return `data:${file.type};base64,${buffer.toString('base64')}`;
}
// Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma.
// Guardamos las fotos como data URI (no hay storage externo en esta fase) y disparamos
// el orquestador que simula la llamada/render y calcula el presupuesto real.
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
const tenantId = await getDemoTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Solicitud no encontrada.');
const tipoRaw = String(formData.get('tipoReforma') ?? '');
const calidadRaw = String(formData.get('calidad') ?? '');
const m2Raw = Number(formData.get('m2'));
const provincia = String(formData.get('provincia') ?? '').trim() || null;
const tipoReforma = (TIPOS as readonly string[]).includes(tipoRaw)
? (tipoRaw as (typeof TIPOS)[number])
: 'otro';
const calidadGlobal = (CALIDADES as readonly string[]).includes(calidadRaw)
? (calidadRaw as (typeof CALIDADES)[number])
: 'media';
const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
const urgenciaRaw = String(formData.get('urgencia') ?? '');
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
? (urgenciaRaw as 'alta' | 'media' | 'baja')
: null;
const targetEuros = Number(formData.get('presupuestoTarget'));
const presupuestoTarget =
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
const estructural = formData.get('estructural') === 'on';
const tasteText = String(formData.get('tasteText') ?? '').trim() || null;
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
const dataUris: string[] = [];
for (const file of archivos.slice(0, MAX_FOTOS)) {
const uri = await fileToDataUri(file);
if (uri) dataUris.push(uri);
}
if (dataUris.length > 0) {
await db.insert(leadFotos).values(
dataUris.map((url, orden) => ({ leadId, url, orden }))
);
}
await db
.update(leads)
.set({
tipoReforma,
calidadGlobal,
m2Suelo,
provincia,
urgencia,
presupuestoTarget,
estructural,
tasteText,
pipelineStage: 'fotos_subidas',
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'fotos_subidas',
metadata: { fotos: dataUris.length },
});
// Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp).
await procesarLead(leadId);
revalidatePath('/panel');
redirect(`/solicitud/${leadId}/estado`);
}

View File

@@ -0,0 +1,26 @@
import Link from 'next/link';
export default function SolicitudLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-white border-b border-gray-200">
<div className="container py-4 flex items-center justify-between">
<Link href="/" className="text-lg font-black tracking-tight text-black">
Reformix
</Link>
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Tu presupuesto
</span>
</div>
</header>
<main className="flex-1">
<div className="container py-10 max-w-2xl">{children}</div>
</main>
<footer className="border-t border-gray-200 bg-white">
<div className="container py-6 text-xs text-gray-400 text-center">
Reformix · Presupuesto orientativo. El precio final puede variar según la visita técnica.
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { deriveCantidades } from './derive';
import { resolvePrecioUnitario } from './resolve';
import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
import type {
BudgetInputs,
BudgetResult,
CategoriaMaterial,
CatalogItem,
PartidaKey,
PricingConfig,
} from './types';
const LICENCIA_MIN = 30000; // 300 €
const LICENCIA_MAX = 150000; // 1.500 €
// A qué partida contribuye el material de cada categoría.
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
suelo: 'alicatado',
pared: 'alicatado',
pintura: 'extras',
mobiliario: 'carpinteria',
};
const CATEGORIAS: CategoriaMaterial[] = ['suelo', 'pared', 'pintura', 'mobiliario'];
export function computeBudget(
inputs: BudgetInputs,
config: PricingConfig,
catalog: CatalogItem[],
): BudgetResult {
const cant = deriveCantidades(inputs, config);
const avisos: string[] = [];
const materialesRender: string[] = [];
const importes: Record<PartidaKey, number> = {
demolicion: 0,
alicatado: 0,
fontaneria: 0,
electricidad: 0,
carpinteria: 0,
mano_de_obra: 0,
extras: 0,
licencia: 0,
};
const cantidadPorCategoria: Record<CategoriaMaterial, number> = {
suelo: cant.m2Suelo,
pared: cant.m2Pared,
pintura: cant.m2Pared,
mobiliario: cant.mlMobiliario,
};
for (const categoria of CATEGORIAS) {
const cantidad = cantidadPorCategoria[categoria];
if (cantidad <= 0) continue;
const { item } = resolvePrecioUnitario(
categoria,
inputs.calidadGlobal,
catalog,
inputs.materialSelections,
);
if (!item) {
avisos.push(`Sin precio para ${categoria} (calidad ${inputs.calidadGlobal})`);
continue;
}
importes[MATERIAL_PARTIDA[categoria]] += Math.round(cantidad * item.precioUnit);
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
}
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria);
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad);
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
key: k,
label: PARTIDA_LABEL[k],
importe: importes[k],
}));
const subtotal = partidas.reduce((s, p) => s + p.importe, 0);
const factorZona = config.factorZona[inputs.provincia ?? ''] ?? 1;
const total = Math.round(subtotal * factorZona);
const hasExact = (Object.values(inputs.materialSelections) as string[]).some(
(id) => catalog.some((c) => c.id === id),
);
const hasM2 = inputs.m2Suelo != null && inputs.m2Suelo > 0;
let confianza: BudgetResult['confianza'];
let band: number;
if (hasM2 && hasExact) {
confianza = 'alta';
band = 0.1;
} else if (hasM2 || hasExact) {
confianza = 'media';
band = 0.15;
} else {
confianza = 'baja';
band = 0.25;
}
const rango = {
min: Math.round(total * (1 - band)),
max: Math.round(total * (1 + band)) + (inputs.estructural ? LICENCIA_MAX - LICENCIA_MIN : 0),
};
return { partidas, subtotal, factorZona, total, rango, confianza, materialesRender, avisos };
}

76
mvp/b2c/src/budget/csv.ts Normal file
View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
import type { CategoriaMaterial, Calidad, Unidad } from './types';
export interface ParsedCatalogRow {
categoria: CategoriaMaterial;
nombre: string;
calidad: Calidad;
precioUnit: number; // céntimos
unidad: Unidad;
descriptorRender: string;
sku: string;
}
export interface CsvError {
line: number; // 1-indexed (la cabecera es la línea 1)
message: string;
}
const HEADER = ['categoria', 'nombre', 'calidad', 'precio', 'unidad', 'descriptor_render', 'sku'];
const rowSchema = z.object({
categoria: z.enum(['suelo', 'pared', 'pintura', 'mobiliario']),
nombre: z.string().min(1),
calidad: z.enum(['basica', 'media', 'premium']),
precio: z
.string()
.transform((s) => Number(s))
.refine((n) => Number.isFinite(n) && n > 0, 'precio inválido'),
unidad: z.enum(['m2', 'ml', 'ud']),
descriptor_render: z.string(),
sku: z.string().min(1),
});
export function parseCatalogCsv(text: string): { rows: ParsedCatalogRow[]; errors: CsvError[] } {
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length === 0) {
return { rows: [], errors: [{ line: 1, message: 'CSV vacío' }] };
}
const header = lines[0].split(',').map((h) => h.trim());
if (HEADER.some((h, i) => header[i] !== h)) {
return {
rows: [],
errors: [{ line: 1, message: `Cabecera inválida. Esperada: ${HEADER.join(',')}` }],
};
}
const rows: ParsedCatalogRow[] = [];
const errors: CsvError[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',').map((c) => c.trim());
const record = Object.fromEntries(HEADER.map((h, idx) => [h, cells[idx] ?? '']));
const parsed = rowSchema.safeParse(record);
if (!parsed.success) {
errors.push({ line: i + 1, message: parsed.error.issues[0]?.message ?? 'fila inválida' });
continue;
}
const d = parsed.data;
rows.push({
categoria: d.categoria,
nombre: d.nombre,
calidad: d.calidad,
precioUnit: Math.round(d.precio * 100),
unidad: d.unidad,
descriptorRender: d.descriptor_render,
sku: d.sku,
});
}
return { rows, errors };
}

View File

@@ -0,0 +1,39 @@
import type { BudgetInputs, PricingConfig, TipoReforma } from './types';
export interface Cantidades {
m2Suelo: number;
m2Pared: number;
mlMobiliario: number;
perimetro: number;
alturaTecho: number;
}
const M2_MEDIANA: Record<TipoReforma, number> = {
cocina: 10,
bano: 5,
salon: 20,
comedor: 16,
integral: 70,
otro: 12,
};
// Metros lineales de mobiliario por metro de perímetro. Solo cocina/baño.
const FACTOR_MOBILIARIO: Partial<Record<TipoReforma, number>> = {
cocina: 0.5,
bano: 0.3,
};
export function deriveCantidades(inputs: BudgetInputs, config: PricingConfig): Cantidades {
const m2Suelo =
inputs.m2Suelo != null && inputs.m2Suelo > 0
? inputs.m2Suelo
: M2_MEDIANA[inputs.tipoReforma];
const alturaTecho =
inputs.alturaTecho != null && inputs.alturaTecho > 0
? inputs.alturaTecho
: config.alturaTechoDefault;
const perimetro = 4 * Math.sqrt(m2Suelo);
const m2Pared = perimetro * alturaTecho;
const mlMobiliario = perimetro * (FACTOR_MOBILIARIO[inputs.tipoReforma] ?? 0);
return { m2Suelo, m2Pared, mlMobiliario, perimetro, alturaTecho };
}

View File

@@ -0,0 +1,30 @@
import type { BudgetResult, PartidaResult } from './types';
export const AVISO_EDITADO = 'Presupuesto ajustado manualmente por el reformista.';
// Aplica la edición manual de conceptos del reformista sobre un presupuesto ya calculado.
// Conserva el factor de zona del cálculo original; el reformista ha validado las cifras,
// así que la confianza pasa a alta y el rango colapsa al total.
export function applyConceptoEdits(prev: BudgetResult, partidas: PartidaResult[]): BudgetResult {
const clean: PartidaResult[] = partidas
.map((p) => ({
key: p.key,
label: p.label.trim(),
importe: Math.max(0, Math.round(p.importe)),
}))
.filter((p) => p.label.length > 0);
const subtotal = clean.reduce((s, p) => s + p.importe, 0);
const total = Math.round(subtotal * prev.factorZona);
const avisos = prev.avisos.includes(AVISO_EDITADO) ? prev.avisos : [...prev.avisos, AVISO_EDITADO];
return {
...prev,
partidas: clean,
subtotal,
total,
rango: { min: total, max: total },
confianza: 'alta',
avisos,
};
}

View File

@@ -0,0 +1,6 @@
export * from './types';
export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
export { deriveCantidades } from './derive';
export { resolvePrecioUnitario } from './resolve';
export { computeBudget } from './compute';
export { parseCatalogCsv } from './csv';

View File

@@ -0,0 +1,23 @@
import type { PartidaKey } from './types';
export const PARTIDA_ORDER: PartidaKey[] = [
'demolicion',
'alicatado',
'fontaneria',
'electricidad',
'carpinteria',
'mano_de_obra',
'extras',
'licencia',
];
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
demolicion: 'Demolición',
alicatado: 'Alicatado y solado',
fontaneria: 'Fontanería',
electricidad: 'Electricidad',
carpinteria: 'Carpintería y mobiliario',
mano_de_obra: 'Mano de obra',
extras: 'Pintura y extras',
licencia: 'Licencia + Proyecto técnico',
};

View File

@@ -0,0 +1,18 @@
import type { Calidad, CategoriaMaterial, CatalogItem } from './types';
export function resolvePrecioUnitario(
categoria: CategoriaMaterial,
calidad: Calidad,
catalog: CatalogItem[],
selections: Partial<Record<CategoriaMaterial, string>>,
): { item: CatalogItem | null } {
const selectedId = selections[categoria];
if (selectedId) {
const selected = catalog.find((c) => c.id === selectedId);
if (selected) return { item: selected };
}
const def = catalog.find(
(c) => c.categoria === categoria && c.calidad === calidad && c.esDefault,
);
return { item: def ?? null };
}

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