Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton
@@ -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 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"
|
||||
|
||||
- **Título:** Le pones la herramienta en tu web. El resto lo hace ella.
|
||||
|
||||
1811
docs/superpowers/plans/2026-05-30-auth-panel-planes.md
Normal file
1610
docs/superpowers/plans/2026-05-30-motor-presupuesto.md
Normal file
1028
docs/superpowers/plans/2026-05-31-guion-agente-voz-preferencias.md
Normal file
173
docs/superpowers/specs/2026-05-30-auth-panel-planes-design.md
Normal 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).
|
||||
137
docs/superpowers/specs/2026-05-30-motor-presupuesto-design.md
Normal 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 300–1.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.
|
||||
@@ -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
@@ -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
|
||||
BIN
mvp/b2b/assets/fonts/00430955-27e4-4cea-b94e-787f11af509f.woff2
Normal file
BIN
mvp/b2b/assets/fonts/12581ec4-115f-4ba1-8b57-50addc6b546c.woff2
Normal file
BIN
mvp/b2b/assets/fonts/158fbdf5-8d90-47a9-bc8e-5d21e3bafce8.woff2
Normal file
BIN
mvp/b2b/assets/fonts/15d36112-e39a-4059-ae12-06c58a5747ac.woff2
Normal file
BIN
mvp/b2b/assets/fonts/3564a927-e275-4816-b6c1-cc99fc1dcdab.woff2
Normal file
BIN
mvp/b2b/assets/fonts/40945437-5659-40b2-bfd8-fd664ad1615e.woff2
Normal file
BIN
mvp/b2b/assets/fonts/421ba28b-7abe-4b86-a87c-fcd3e94378f7.woff2
Normal file
BIN
mvp/b2b/assets/fonts/49e48789-614f-48bf-9256-df3caab8c123.woff2
Normal file
BIN
mvp/b2b/assets/fonts/4d99cd2e-393d-4b95-bcfa-8f2a24ac6f7b.woff2
Normal file
BIN
mvp/b2b/assets/fonts/556fa156-4d6a-481e-a43b-d7ab5fb934a9.woff2
Normal file
BIN
mvp/b2b/assets/fonts/60f0ecce-40bf-4952-a5d0-c64eb7d90838.woff2
Normal file
BIN
mvp/b2b/assets/fonts/a7d33cb8-a5f1-44bf-bf3c-564e7ea0619a.woff2
Normal file
BIN
mvp/b2b/assets/fonts/af8b43b5-1741-407a-be2e-9881a5996d70.woff2
Normal file
BIN
mvp/b2b/assets/fonts/c4f99f5c-1856-4a0e-9de5-7834190c77b0.woff2
Normal file
BIN
mvp/b2b/assets/fonts/c8bfb9ea-7aca-4f8d-8f7c-3e648ef6cb5b.woff2
Normal file
BIN
mvp/b2b/assets/fonts/ca40d6e0-4a50-4481-8e30-74c159ef9cee.woff2
Normal file
BIN
mvp/b2b/assets/fonts/e9c2548b-cd31-41e0-bc94-aac65cc4b6eb.woff2
Normal file
BIN
mvp/b2b/assets/img/antes-bano.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
mvp/b2b/assets/img/antes-comedor.webp
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
mvp/b2b/assets/img/antes.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
mvp/b2b/assets/img/despues-bano.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
mvp/b2b/assets/img/despues-comedor.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
mvp/b2b/assets/img/despues.webp
Normal file
|
After Width: | Height: | Size: 153 KiB |
4
mvp/b2b/assets/img/icon.svg
Normal 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
3
mvp/b2c/.env.example
Normal 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
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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/.next ./.next
|
||||
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
|
||||
CMD ["npm", "run", "start"]
|
||||
CMD ["./docker-entrypoint.sh"]
|
||||
|
||||
@@ -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
|
||||
|
||||
GitHub: [McGregory99/reformix-hackaton](https://github.com/McGregory99/reformix-hackaton)
|
||||
|
||||
11
mvp/b2c/docker-entrypoint.sh
Normal 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
@@ -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,
|
||||
});
|
||||
77
mvp/b2c/drizzle/0000_motionless_jackpot.sql
Normal 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");
|
||||
36
mvp/b2c/drizzle/0001_bored_preak.sql
Normal 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");
|
||||
45
mvp/b2c/drizzle/0002_overjoyed_the_renegades.sql
Normal 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;
|
||||
2
mvp/b2c/drizzle/0003_youthful_white_queen.sql
Normal 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;
|
||||
5
mvp/b2c/drizzle/0004_even_stranger.sql
Normal 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;
|
||||
5
mvp/b2c/drizzle/0005_tearful_maverick.sql
Normal 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;
|
||||
561
mvp/b2c/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
834
mvp/b2c/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
1161
mvp/b2c/drizzle/meta/0002_snapshot.json
Normal file
1177
mvp/b2c/drizzle/meta/0003_snapshot.json
Normal file
1207
mvp/b2c/drizzle/meta/0004_snapshot.json
Normal file
1241
mvp/b2c/drizzle/meta/0005_snapshot.json
Normal file
48
mvp/b2c/drizzle/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
|
||||
serverExternalPackages: ['@react-pdf/renderer'],
|
||||
async rewrites() {
|
||||
return [
|
||||
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.
|
||||
|
||||
3648
mvp/b2c/package-lock.json
generated
@@ -6,22 +6,40 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"postcss": "^8.5.15",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwindcss": "^4.3.0"
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"typescript": "^5"
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
mvp/b2c/public/antes-bano.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
mvp/b2c/public/antes-comedor.webp
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
mvp/b2c/public/antes.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
mvp/b2c/public/despues-bano.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
mvp/b2c/public/despues-comedor.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
mvp/b2c/public/despues.webp
Normal file
|
After Width: | Height: | Size: 153 KiB |
4
mvp/b2c/public/icon.svg
Normal 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 |
32
mvp/b2c/src/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
mvp/b2c/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
mvp/b2c/src/app/admin/planes/actions.ts
Normal 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');
|
||||
}
|
||||
61
mvp/b2c/src/app/admin/planes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
mvp/b2c/src/app/admin/usuarios/CrearReformistaForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
mvp/b2c/src/app/admin/usuarios/actions.ts
Normal 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');
|
||||
}
|
||||
45
mvp/b2c/src/app/admin/usuarios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -51,7 +51,8 @@
|
||||
color: var(--color-dark);
|
||||
background-color: var(--color-white);
|
||||
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 */
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FlowSync — El SaaS que impulsa tu equipo',
|
||||
title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
|
||||
description:
|
||||
'Automatiza flujos de trabajo, conecta equipos y escala tu negocio con FlowSync. La plataforma SaaS todo-en-uno para equipos modernos.',
|
||||
keywords: ['SaaS', 'productividad', 'automatización', 'equipos', 'gestión de proyectos'],
|
||||
'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: ['reforma', 'presupuesto reforma', 'render reforma', 'cocina', 'baño', 'reformistas'],
|
||||
icons: { icon: '/icon.svg' },
|
||||
openGraph: {
|
||||
title: 'FlowSync — El SaaS que impulsa tu equipo',
|
||||
description: 'Automatiza flujos de trabajo y escala tu negocio con FlowSync.',
|
||||
title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
|
||||
description: 'Render y presupuesto orientativo de tu reforma en minutos, por WhatsApp.',
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
|
||||
19
mvp/b2c/src/app/login/actions.ts
Normal 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');
|
||||
}
|
||||
27
mvp/b2c/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
mvp/b2c/src/app/logout/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { destroySession } from '@/lib/auth/session';
|
||||
|
||||
export async function POST() {
|
||||
await destroySession();
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Navbar from '@/components/Navbar/Navbar';
|
||||
import Hero from '@/components/Hero/Hero';
|
||||
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
||||
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 Footer from '@/components/Footer/Footer';
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function Home() {
|
||||
<Hero />
|
||||
<ReformaSlider />
|
||||
<Features />
|
||||
<Pricing />
|
||||
<Testimonials />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
369
mvp/b2c/src/app/panel/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
172
mvp/b2c/src/app/panel/actions.ts
Normal 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}`);
|
||||
}
|
||||
66
mvp/b2c/src/app/panel/empresa/actions.ts
Normal 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');
|
||||
}
|
||||
93
mvp/b2c/src/app/panel/empresa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
mvp/b2c/src/app/panel/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
mvp/b2c/src/app/panel/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
mvp/b2c/src/app/panel/precios/actions.ts
Normal 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: [] };
|
||||
}
|
||||
244
mvp/b2c/src/app/panel/precios/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
mvp/b2c/src/app/signup/actions.ts
Normal 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');
|
||||
}
|
||||
29
mvp/b2c/src/app/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
mvp/b2c/src/app/solicitud/[id]/estado/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
mvp/b2c/src/app/solicitud/[id]/fotos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
mvp/b2c/src/app/solicitud/actions.ts
Normal 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`);
|
||||
}
|
||||
26
mvp/b2c/src/app/solicitud/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
mvp/b2c/src/budget/compute.ts
Normal 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
@@ -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 };
|
||||
}
|
||||
39
mvp/b2c/src/budget/derive.ts
Normal 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 };
|
||||
}
|
||||
30
mvp/b2c/src/budget/edit.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
6
mvp/b2c/src/budget/index.ts
Normal 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';
|
||||
23
mvp/b2c/src/budget/labels.ts
Normal 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',
|
||||
};
|
||||
18
mvp/b2c/src/budget/resolve.ts
Normal 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 };
|
||||
}
|
||||