Add B2B reformista panel with Postgres/Drizzle data layer

Modela el funnel del lead en dos dimensiones (pipeline_stage técnico
de 7 pasos + estado comercial de 6 estados) y siembra 11 leads demo,
uno por cada momento del funnel, para analizar el siguiente paso.
Incluye panel /panel (lista + detalle RF-D-01/02) y wiring de deploy
(Dockerfile multi-stage + entrypoint migrate+seed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-29 15:51:10 +02:00
parent 9020c24e68
commit f09024f753
20 changed files with 3630 additions and 2 deletions

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

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

1
mvp/b2c/.gitignore vendored
View File

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

View File

@@ -13,5 +13,12 @@ COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.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"]

View File

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780056789929,
"tag": "0000_motionless_jackpot",
"breakpoints": true
}
]
}

1645
mvp/b2c/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,19 @@
"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"
},
"dependencies": {
"@tailwindcss/postcss": "^4.3.0",
"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"
@@ -20,8 +27,11 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tsx": "^4.22.3",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,213 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
PIPELINE_ORDER,
TIPO_LABEL,
formatEuros,
formatFecha,
} from '@/lib/funnel';
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));
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>
{/* 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)">
{lead.pdfUrl ? (
<a
href={lead.pdfUrl}
download
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>
) : (
<p className="text-sm text-gray-400">Aún no generado.</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>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadEstadoHistory, precisionHistory, tenants } from '@/db/schema';
import { TENANT_SLUG } from '@/lib/funnel';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error('Tenant no encontrado.');
return tenant.id;
}
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(eq(leads.id, leadId));
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}`);
}

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Panel · Reformas Ejemplo',
description: 'Panel de leads del reformista',
};
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-3">
<span className="inline-flex 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="text-gray-300">/</span>
<span className="text-sm font-medium text-gray-600">Reformas Ejemplo</span>
</Link>
<span className="text-xs font-medium text-gray-400">Panel del reformista</span>
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,152 @@
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';
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()]);
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>
{/* Filtros por estado */}
<div className="flex flex-wrap gap-2">
{FILTROS.map((f) => {
const active = f.value === filtro;
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
return (
<Link
key={f.value}
href={f.value === 'todos' ? '/panel' : `/panel?estado=${f.value}`}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-black text-white border-black'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
{f.label} <span className={active ? 'text-gray-300' : 'text-gray-400'}>{count}</span>
</Link>
);
})}
</div>
{/* Tabla (desktop) */}
<div className="hidden md:block bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3 font-semibold">Render</th>
<th className="px-4 py-3 font-semibold">Cliente</th>
<th className="px-4 py-3 font-semibold">Fecha</th>
<th className="px-4 py-3 font-semibold">Estado</th>
<th className="px-4 py-3 font-semibold">Presupuesto</th>
<th className="px-4 py-3 font-semibold">Siguiente paso</th>
</tr>
</thead>
<tbody>
{leads.map((l) => (
<tr key={l.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="block w-16 h-12 rounded-md overflow-hidden bg-gray-100">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="flex w-full h-full items-center justify-center text-[10px] text-gray-400">
sin render
</span>
)}
</Link>
</td>
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="font-semibold text-black hover:underline">
{l.nombre}
</Link>
<div className="text-gray-500">{l.telefono}</div>
</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatFecha(l.createdAt)}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</td>
<td className="px-4 py-3 font-semibold text-black whitespace-nowrap">
{formatEuros(l.presupuestoEstimado)}
</td>
<td className="px-4 py-3 text-gray-500">
<div className="text-xs text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</div>
{PIPELINE_NEXT[l.pipelineStage]}
</td>
</tr>
))}
</tbody>
</table>
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
{/* Cards (mobile) */}
<div className="md:hidden flex flex-col gap-3">
{leads.map((l) => (
<Link
key={l.id}
href={`/panel/${l.id}`}
className="bg-white border border-gray-200 rounded-xl p-4 flex gap-3"
>
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-100 shrink-0">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : null}
</div>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-black truncate">{l.nombre}</span>
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</div>
<div className="text-sm text-gray-500">{l.telefono}</div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-black">{formatEuros(l.presupuestoEstimado)}</span>
<span className="text-gray-400">{formatFecha(l.createdAt)}</span>
</div>
<div className="text-xs text-gray-400">{PIPELINE_NEXT[l.pipelineStage]}</div>
</div>
</Link>
))}
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState, useTransition } from 'react';
import { cambiarEstado, marcarGanado } from '@/app/panel/actions';
import { ESTADOS, ESTADO_BADGE, ESTADO_LABEL, formatEuros } from '@/lib/funnel';
type Estado = (typeof ESTADOS)[number];
export default function EstadoControl({
leadId,
estado,
presupuestoEstimado,
}: {
leadId: string;
estado: Estado;
presupuestoEstimado: number | null;
}) {
const [pending, startTransition] = useTransition();
const [modalOpen, setModalOpen] = useState(false);
const [precio, setPrecio] = useState(
presupuestoEstimado != null ? String(Math.round(presupuestoEstimado / 100)) : ''
);
const [error, setError] = useState<string | null>(null);
function onSelect(nuevo: Estado) {
if (nuevo === estado) return;
if (nuevo === 'ganado') {
setModalOpen(true);
return;
}
setError(null);
startTransition(async () => {
await cambiarEstado(leadId, nuevo);
});
}
function confirmarGanado() {
const euros = Number(precio);
if (!Number.isFinite(euros) || euros <= 0) {
setError('Introduce un precio final válido.');
return;
}
setError(null);
startTransition(async () => {
await marcarGanado(leadId, euros);
setModalOpen(false);
});
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{ESTADOS.map((e) => {
const active = e === estado;
return (
<button
key={e}
type="button"
disabled={pending}
onClick={() => onSelect(e)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors disabled:opacity-50 ${
active
? `${ESTADO_BADGE[e]} border-transparent ring-2 ring-black/80`
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
{ESTADO_LABEL[e]}
</button>
);
})}
</div>
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-xl p-6 w-full max-w-sm flex flex-col gap-4 shadow-xl">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-extrabold text-black">Marcar como Ganado</h3>
<p className="text-sm text-gray-500">
Introduce el precio final firmado. Calcularemos la desviación vs el presupuesto
estimado ({formatEuros(presupuestoEstimado)}).
</p>
</div>
<label className="flex flex-col gap-1 text-sm font-medium text-gray-700">
Precio final firmado ()
<input
type="number"
min="0"
value={precio}
onChange={(e) => setPrecio(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-black focus:outline-none focus:border-black"
autoFocus
/>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={() => setModalOpen(false)}
disabled={pending}
className="px-4 py-2 rounded-lg text-sm font-semibold text-gray-600 hover:bg-gray-100"
>
Cancelar
</button>
<button
type="button"
onClick={confirmarGanado}
disabled={pending}
className="px-4 py-2 rounded-lg text-sm font-semibold bg-black text-white hover:bg-gray-800 disabled:opacity-50"
>
{pending ? 'Guardando…' : 'Confirmar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

29
mvp/b2c/src/db/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
// Cliente perezoso: solo se conecta en el primer acceso real a la DB.
// Evita que `next build` (que importa los módulos de ruta) falle si no hay
// DATABASE_URL en tiempo de build.
let _db: PostgresJsDatabase<typeof schema> | null = null;
function getDb(): PostgresJsDatabase<typeof schema> {
if (_db) return _db;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL no está definida. Copia .env.example a .env.local y rellénala.');
}
const client = postgres(connectionString, { prepare: false });
_db = drizzle(client, { schema });
return _db;
}
export const db = new Proxy({} as PostgresJsDatabase<typeof schema>, {
get(_target, prop) {
const instance = getDb();
const value = instance[prop as keyof typeof instance];
return typeof value === 'function' ? value.bind(instance) : value;
},
});
export { schema };

66
mvp/b2c/src/db/queries.ts Normal file
View File

@@ -0,0 +1,66 @@
import { and, asc, desc, eq } from 'drizzle-orm';
import { db } from './index';
import {
leads,
leadFotos,
leadEstadoHistory,
leadPipelineEventos,
precisionHistory,
tenants,
} from './schema';
import { TENANT_SLUG } from '@/lib/funnel';
async function getTenantId(): Promise<string> {
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1);
if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`);
return tenant.id;
}
export type LeadFiltro = (typeof leads.estado.enumValues)[number] | 'todos';
export async function getLeads(filtro: LeadFiltro = 'todos') {
const tenantId = await getTenantId();
const where =
filtro === 'todos'
? eq(leads.tenantId, tenantId)
: and(eq(leads.tenantId, tenantId), eq(leads.estado, filtro));
return db.select().from(leads).where(where).orderBy(desc(leads.createdAt));
}
export async function getLead(id: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, id), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) return null;
const [fotos, eventos, historial, precision] = await Promise.all([
db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
db
.select()
.from(leadPipelineEventos)
.where(eq(leadPipelineEventos.leadId, id))
.orderBy(asc(leadPipelineEventos.occurredAt)),
db
.select()
.from(leadEstadoHistory)
.where(eq(leadEstadoHistory.leadId, id))
.orderBy(asc(leadEstadoHistory.changedAt)),
db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)),
]);
return { lead, fotos, eventos, historial, precision: precision[0] ?? null };
}
export async function getResumen() {
const all = await getLeads('todos');
const porEstado = all.reduce<Record<string, number>>((acc, l) => {
acc[l.estado] = (acc[l.estado] ?? 0) + 1;
return acc;
}, {});
return { total: all.length, porEstado };
}

153
mvp/b2c/src/db/schema.ts Normal file
View File

@@ -0,0 +1,153 @@
import {
pgTable,
pgEnum,
uuid,
text,
integer,
boolean,
numeric,
timestamp,
jsonb,
index,
} from 'drizzle-orm/pg-core';
// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano.
export const leadEstado = pgEnum('lead_estado', [
'nuevo',
'contactado',
'visita_agendada',
'presupuesto_enviado',
'ganado',
'perdido',
]);
// Avance técnico en el funnel B2C (docs/funnel.md, superficie C).
// Cada valor = un momento del pipeline; el "siguiente paso" se deriva de aquí.
export const pipelineStage = pgEnum('pipeline_stage', [
'form_completado', // 2. dejó nombre+tel+email+opt-in
'fotos_subidas', // 3. subió 2-4 fotos
'prellamada_enviada', // 4. SMS + WhatsApp pre-llamada
'llamada_completada', // 5. agente IA terminó la cualificación
'render_generado', // 6. Nano Banana generó el render
'presupuesto_generado', // 6. motor de presupuesto + PDF listos
'whatsapp_entregado', // 7. entregado al cliente + lead caliente al panel
]);
export const tipoReforma = pgEnum('tipo_reforma', [
'cocina',
'bano',
'salon',
'comedor',
'integral',
'otro',
]);
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
slug: text('slug').notNull().unique(),
nombreEmpresa: text('nombre_empresa').notNull(),
logoUrl: text('logo_url'),
provincia: text('provincia'),
whatsappBusiness: text('whatsapp_business'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const leads = pgTable(
'leads',
{
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id')
.notNull()
.references(() => tenants.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
// Datos del cliente final (paso 2 del funnel)
nombre: text('nombre').notNull(),
telefono: text('telefono').notNull(),
email: text('email').notNull(),
provincia: text('provincia'),
tipoReforma: tipoReforma('tipo_reforma'),
// Consentimientos LSSI-CE + RGPD (RF-LEG-01)
consentPrivacidad: boolean('consent_privacidad').notNull().default(false),
consentContratacion: boolean('consent_contratacion').notNull().default(false),
// Posición en el funnel y estado comercial
pipelineStage: pipelineStage('pipeline_stage').notNull().default('form_completado'),
estado: leadEstado('estado').notNull().default('nuevo'),
// Presupuesto orientativo en céntimos de euro (evita floats)
presupuestoEstimado: integer('presupuesto_estimado'),
// Artefactos generados por el pipeline (RF-D-02)
transcripcion: text('transcripcion'),
entidades: jsonb('entidades'),
renderUrl: text('render_url'),
pdfUrl: text('pdf_url'),
audioUrl: text('audio_url'),
notas: text('notas'),
},
(table) => [
index('leads_tenant_created_idx').on(table.tenantId, table.createdAt),
index('leads_estado_idx').on(table.estado),
]
);
// Fotos subidas por el cliente (paso 3, 2-4 fotos)
export const leadFotos = pgTable('lead_fotos', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
url: text('url').notNull(),
orden: integer('orden').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar)
export const leadEstadoHistory = pgTable('lead_estado_history', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
estado: leadEstado('estado').notNull(),
changedAt: timestamp('changed_at', { withTimezone: true }).notNull().defaultNow(),
changedBy: text('changed_by'),
});
// Timeline del funnel: una fila por cada paso alcanzado.
// Permite ver dónde está el lead y cuál es el siguiente paso.
export const leadPipelineEventos = pgTable('lead_pipeline_eventos', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
stage: pipelineStage('stage').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(),
metadata: jsonb('metadata'),
});
// Bucle de precisión (RF-D-04): precio final firmado vs estimado.
export const precisionHistory = pgTable('precision_history', {
id: uuid('id').primaryKey().defaultRandom(),
leadId: uuid('lead_id')
.notNull()
.references(() => leads.id, { onDelete: 'cascade' }),
estimated: integer('estimated').notNull(), // céntimos
final: integer('final').notNull(), // céntimos
deltaPct: numeric('delta_pct', { precision: 6, scale: 2 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export type Tenant = typeof tenants.$inferSelect;
export type Lead = typeof leads.$inferSelect;
export type NewLead = typeof leads.$inferInsert;
export type LeadFoto = typeof leadFotos.$inferSelect;
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
export type PrecisionHistory = typeof precisionHistory.$inferSelect;

373
mvp/b2c/src/db/seed.ts Normal file
View File

@@ -0,0 +1,373 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import { eq, sql } from 'drizzle-orm';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL no está definida.');
}
const client = postgres(connectionString, { prepare: false });
const db = drizzle(client, { schema });
const euros = (n: number) => Math.round(n * 100); // a céntimos
// Cada lead vive en un momento distinto del funnel para poder analizar
// cuál es el siguiente paso de cada uno. days = hace cuántos días entró.
type SeedLead = {
nombre: string;
telefono: string;
email: string;
provincia: string;
tipoReforma: (typeof schema.tipoReforma.enumValues)[number];
pipelineStage: (typeof schema.pipelineStage.enumValues)[number];
estado: (typeof schema.leadEstado.enumValues)[number];
presupuestoEstimado: number | null;
transcripcion: string | null;
entidades: Record<string, unknown> | null;
renderUrl: string | null;
pdfUrl: string | null;
audioUrl: string | null;
fotos: string[];
daysAgo: number;
precioFinal?: number; // solo para ganados
};
const SEED_LEADS: SeedLead[] = [
{
// 1. Acaba de dejar sus datos. Siguiente paso: subir fotos.
nombre: 'Lucía Fernández',
telefono: '+34 612 003 451',
email: 'lucia.fernandez@example.com',
provincia: 'Madrid',
tipoReforma: 'cocina',
pipelineStage: 'form_completado',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion: null,
entidades: null,
renderUrl: null,
pdfUrl: null,
audioUrl: null,
fotos: [],
daysAgo: 0,
},
{
// 2. Subió fotos. Siguiente paso: pre-llamada SMS+WhatsApp.
nombre: 'Javier Ortega',
telefono: '+34 633 118 902',
email: 'javier.ortega@example.com',
provincia: 'Valencia',
tipoReforma: 'bano',
pipelineStage: 'fotos_subidas',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion: null,
entidades: null,
renderUrl: null,
pdfUrl: null,
audioUrl: null,
fotos: ['/antes-bano.webp'],
daysAgo: 0,
},
{
// 3. Pre-llamada enviada, pendiente de descolgar. Siguiente: llamada agente.
nombre: 'Marta Ruiz',
telefono: '+34 655 740 213',
email: 'marta.ruiz@example.com',
provincia: 'Sevilla',
tipoReforma: 'comedor',
pipelineStage: 'prellamada_enviada',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion: null,
entidades: null,
renderUrl: null,
pdfUrl: null,
audioUrl: null,
fotos: ['/antes-comedor.webp'],
daysAgo: 1,
},
{
// 4. Llamada completada. Siguiente: render IA.
nombre: 'Andrés Gil',
telefono: '+34 677 552 008',
email: 'andres.gil@example.com',
provincia: 'Málaga',
tipoReforma: 'cocina',
pipelineStage: 'llamada_completada',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion:
'Agente: Hola Andrés, te llamo de Reformas Ejemplo. Te aviso de que soy un asistente con IA y de que la llamada se graba. ¿Quieres reformar la cocina entera? Andrés: Sí, son unos 12 metros, quiero quitar un tabique...',
entidades: {
espacio: 'cocina',
m2_aprox: 12,
tirar_tabique: true,
calidad: 'media',
licencia_urbanistica: 'posible',
},
renderUrl: null,
pdfUrl: null,
audioUrl: '/demo/audio-andres.mp3',
fotos: ['/antes.webp'],
daysAgo: 1,
},
{
// 5. Render generado, falta presupuesto+PDF. Siguiente: motor presupuesto.
nombre: 'Patricia Núñez',
telefono: '+34 688 410 776',
email: 'patricia.nunez@example.com',
provincia: 'Zaragoza',
tipoReforma: 'bano',
pipelineStage: 'render_generado',
estado: 'nuevo',
presupuestoEstimado: null,
transcripcion:
'Agente: Hola Patricia... Patricia: Quiero cambiar la bañera por un plato de ducha y alicatar todo.',
entidades: {
espacio: 'bano',
m2_aprox: 6,
banera_por_ducha: true,
alicatado_completo: true,
calidad: 'media',
},
renderUrl: '/despues-bano.webp',
pdfUrl: null,
audioUrl: '/demo/audio-patricia.mp3',
fotos: ['/antes-bano.webp'],
daysAgo: 2,
},
{
// 6. Presupuesto + PDF listos, sin entregar aún. Siguiente: WhatsApp.
nombre: 'Roberto Salas',
telefono: '+34 699 320 145',
email: 'roberto.salas@example.com',
provincia: 'Barcelona',
tipoReforma: 'integral',
pipelineStage: 'presupuesto_generado',
estado: 'nuevo',
presupuestoEstimado: euros(28400),
transcripcion:
'Agente: Hola Roberto... Roberto: Es un piso de 70 metros, lo quiero reformar entero, suelo, baño, cocina y pintura.',
entidades: {
espacio: 'integral',
m2_aprox: 70,
incluye: ['suelo', 'bano', 'cocina', 'pintura'],
calidad: 'media',
},
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-roberto.pdf',
audioUrl: '/demo/audio-roberto.mp3',
fotos: ['/antes.webp', '/antes-comedor.webp'],
daysAgo: 2,
},
{
// 7. Entregado al cliente por WhatsApp. Lead caliente recién llegado al panel.
nombre: 'Elena Castro',
telefono: '+34 600 781 459',
email: 'elena.castro@example.com',
provincia: 'Madrid',
tipoReforma: 'cocina',
pipelineStage: 'whatsapp_entregado',
estado: 'nuevo',
presupuestoEstimado: euros(15600),
transcripcion:
'Agente: Hola Elena... Elena: Quiero renovar la cocina, muebles nuevos y encimera de cuarzo.',
entidades: {
espacio: 'cocina',
m2_aprox: 10,
muebles_nuevos: true,
encimera: 'cuarzo',
calidad: 'media-alta',
},
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-elena.pdf',
audioUrl: '/demo/audio-elena.mp3',
fotos: ['/antes.webp'],
daysAgo: 3,
},
{
// Entregado y ya contactado por el reformista. Siguiente: agendar visita.
nombre: 'Tomás Herrero',
telefono: '+34 611 902 334',
email: 'tomas.herrero@example.com',
provincia: 'Bilbao',
tipoReforma: 'bano',
pipelineStage: 'whatsapp_entregado',
estado: 'contactado',
presupuestoEstimado: euros(9800),
transcripcion: 'Agente: Hola Tomás... Tomás: Solo el baño, cambiar todo el alicatado y sanitarios.',
entidades: { espacio: 'bano', m2_aprox: 5, sanitarios_nuevos: true, calidad: 'media' },
renderUrl: '/despues-bano.webp',
pdfUrl: '/demo/presupuesto-tomas.pdf',
audioUrl: '/demo/audio-tomas.mp3',
fotos: ['/antes-bano.webp'],
daysAgo: 5,
},
{
// Visita agendada. Siguiente: hacer la visita y enviar presupuesto firmado.
nombre: 'Carmen Ibáñez',
telefono: '+34 622 145 870',
email: 'carmen.ibanez@example.com',
provincia: 'Madrid',
tipoReforma: 'comedor',
pipelineStage: 'whatsapp_entregado',
estado: 'visita_agendada',
presupuestoEstimado: euros(7200),
transcripcion: 'Agente: Hola Carmen... Carmen: Quiero abrir el comedor al salón y poner tarima.',
entidades: { espacio: 'comedor', m2_aprox: 20, tarima: true, calidad: 'media' },
renderUrl: '/despues-comedor.webp',
pdfUrl: '/demo/presupuesto-carmen.pdf',
audioUrl: '/demo/audio-carmen.mp3',
fotos: ['/antes-comedor.webp'],
daysAgo: 8,
},
{
// Ganado: precio final firmado, alimenta precision_history. RF-D-04.
nombre: 'Diego Romero',
telefono: '+34 633 770 218',
email: 'diego.romero@example.com',
provincia: 'Valencia',
tipoReforma: 'integral',
pipelineStage: 'whatsapp_entregado',
estado: 'ganado',
presupuestoEstimado: euros(31000),
transcripcion: 'Agente: Hola Diego... Diego: Reforma integral de un piso de 85 metros heredado.',
entidades: { espacio: 'integral', m2_aprox: 85, calidad: 'alta' },
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-diego.pdf',
audioUrl: '/demo/audio-diego.mp3',
fotos: ['/antes.webp'],
daysAgo: 25,
precioFinal: euros(33500),
},
{
// Perdido: el cliente no siguió adelante.
nombre: 'Sara Blanco',
telefono: '+34 644 318 092',
email: 'sara.blanco@example.com',
provincia: 'Sevilla',
tipoReforma: 'cocina',
pipelineStage: 'whatsapp_entregado',
estado: 'perdido',
presupuestoEstimado: euros(14200),
transcripcion: 'Agente: Hola Sara... Sara: Quería una idea de precio para la cocina.',
entidades: { espacio: 'cocina', m2_aprox: 9, calidad: 'media' },
renderUrl: '/despues.webp',
pdfUrl: '/demo/presupuesto-sara.pdf',
audioUrl: '/demo/audio-sara.mp3',
fotos: ['/antes.webp'],
daysAgo: 18,
},
];
// Orden cronológico del funnel para reconstruir el timeline de cada lead.
const STAGE_ORDER = schema.pipelineStage.enumValues;
async function main() {
const [existing] = await db
.select()
.from(schema.tenants)
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
.limit(1);
if (existing && !process.env.SEED_FORCE) {
console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.');
await client.end();
return;
}
console.log('Limpiando datos previos...');
await db.execute(
sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.tenants} RESTART IDENTITY CASCADE`
);
console.log('Creando tenant "Reformas Ejemplo"...');
const [tenant] = await db
.insert(schema.tenants)
.values({
slug: 'reformas-ejemplo',
nombreEmpresa: 'Reformas Ejemplo',
provincia: 'Madrid',
whatsappBusiness: '+34 600 000 000',
})
.returning();
console.log(`Insertando ${SEED_LEADS.length} leads...`);
for (const l of SEED_LEADS) {
const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000);
const [lead] = await db
.insert(schema.leads)
.values({
tenantId: tenant.id,
createdAt,
updatedAt: createdAt,
nombre: l.nombre,
telefono: l.telefono,
email: l.email,
provincia: l.provincia,
tipoReforma: l.tipoReforma,
consentPrivacidad: true,
consentContratacion: true,
pipelineStage: l.pipelineStage,
estado: l.estado,
presupuestoEstimado: l.presupuestoEstimado,
transcripcion: l.transcripcion,
entidades: l.entidades,
renderUrl: l.renderUrl,
pdfUrl: l.pdfUrl,
audioUrl: l.audioUrl,
})
.returning();
// Fotos
if (l.fotos.length) {
await db.insert(schema.leadFotos).values(
l.fotos.map((url, orden) => ({ leadId: lead.id, url, orden }))
);
}
// Timeline del funnel: un evento por cada paso alcanzado hasta el actual.
const reached = STAGE_ORDER.slice(0, STAGE_ORDER.indexOf(l.pipelineStage) + 1);
await db.insert(schema.leadPipelineEventos).values(
reached.map((stage, i) => ({
leadId: lead.id,
stage,
occurredAt: new Date(createdAt.getTime() + i * 5 * 60 * 1000),
}))
);
// Histórico de estado (siempre nace en 'nuevo')
const estados: (typeof schema.leadEstado.enumValues)[number][] =
l.estado === 'nuevo' ? ['nuevo'] : ['nuevo', l.estado];
await db.insert(schema.leadEstadoHistory).values(
estados.map((estado, i) => ({
leadId: lead.id,
estado,
changedAt: new Date(createdAt.getTime() + i * 60 * 60 * 1000),
}))
);
// Precisión para ganados (RF-D-04)
if (l.estado === 'ganado' && l.precioFinal && l.presupuestoEstimado) {
const deltaPct = ((l.precioFinal - l.presupuestoEstimado) / l.presupuestoEstimado) * 100;
await db.insert(schema.precisionHistory).values({
leadId: lead.id,
estimated: l.presupuestoEstimado,
final: l.precioFinal,
deltaPct: deltaPct.toFixed(2),
});
}
}
console.log('Seed completado.');
await client.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

93
mvp/b2c/src/lib/funnel.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { leadEstado, pipelineStage, tipoReforma } from '@/db/schema';
export const TENANT_SLUG = 'reformas-ejemplo';
type Estado = (typeof leadEstado.enumValues)[number];
type Stage = (typeof pipelineStage.enumValues)[number];
type Tipo = (typeof tipoReforma.enumValues)[number];
export const ESTADOS: Estado[] = [
'nuevo',
'contactado',
'visita_agendada',
'presupuesto_enviado',
'ganado',
'perdido',
];
export const ESTADO_LABEL: Record<Estado, string> = {
nuevo: 'Nuevo',
contactado: 'Contactado',
visita_agendada: 'Visita agendada',
presupuesto_enviado: 'Presupuesto enviado',
ganado: 'Ganado',
perdido: 'Perdido',
};
// Clases Tailwind para el badge de cada estado (fondo + texto).
export const ESTADO_BADGE: Record<Estado, string> = {
nuevo: 'bg-blue-100 text-blue-700',
contactado: 'bg-amber-100 text-amber-700',
visita_agendada: 'bg-violet-100 text-violet-700',
presupuesto_enviado: 'bg-cyan-100 text-cyan-700',
ganado: 'bg-green-100 text-green-700',
perdido: 'bg-gray-200 text-gray-600',
};
export const PIPELINE_ORDER: Stage[] = [
'form_completado',
'fotos_subidas',
'prellamada_enviada',
'llamada_completada',
'render_generado',
'presupuesto_generado',
'whatsapp_entregado',
];
export const PIPELINE_LABEL: Record<Stage, string> = {
form_completado: 'Datos recibidos',
fotos_subidas: 'Fotos subidas',
prellamada_enviada: 'Pre-llamada enviada',
llamada_completada: 'Llamada completada',
render_generado: 'Render generado',
presupuesto_generado: 'Presupuesto generado',
whatsapp_entregado: 'Entregado por WhatsApp',
};
// Qué falta para que el lead avance. Permite analizar el siguiente paso.
export const PIPELINE_NEXT: Record<Stage, string> = {
form_completado: 'Esperando que suba fotos',
fotos_subidas: 'Lanzar pre-llamada (SMS + WhatsApp)',
prellamada_enviada: 'Llamada del agente IA',
llamada_completada: 'Generar render IA',
render_generado: 'Calcular presupuesto + PDF',
presupuesto_generado: 'Entregar al cliente por WhatsApp',
whatsapp_entregado: 'Lead listo: contactar al cliente',
};
export const TIPO_LABEL: Record<Tipo, string> = {
cocina: 'Cocina',
bano: 'Baño',
salon: 'Salón',
comedor: 'Comedor',
integral: 'Reforma integral',
otro: 'Otro',
};
export function formatEuros(cents: number | null): string {
if (cents == null) return '—';
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
}).format(cents / 100);
}
export function formatFecha(date: Date): string {
return new Intl.DateTimeFormat('es-ES', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}