Añade revisión pre-envío del reformista y PDF de presupuesto pulido

Adelanta de F1.5 a F2 la validación pre-envío: el panel permite elegir
modo de envío (automático/revisión), editar los conceptos del
presupuesto y enviar al cliente por WhatsApp (simulado).

Añade datos de empresa y logo configurables en /panel/empresa y genera
el presupuesto como PDF real descargable con esa marca vía
@react-pdf/renderer, sustituyendo la vista HTML imprimible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-05-30 22:27:05 +02:00
parent b84b2f37a2
commit ec141cdd6e
26 changed files with 3961 additions and 59 deletions

View File

@@ -2,6 +2,7 @@ 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,
@@ -10,7 +11,7 @@ import {
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { recalcularPresupuesto } from '../actions';
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
@@ -34,6 +35,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
return (
<div className="flex flex-col gap-6">
@@ -216,52 +218,42 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<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 presupuesto
</button>
</form>
<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">
{/* Partidas */}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
<th className="pb-2 font-semibold">Partida</th>
<th className="pb-2 font-semibold text-right">Importe</th>
</tr>
</thead>
<tbody>
{desglose.partidas.map((partida) => (
<tr key={partida.key} className="border-b border-gray-50">
<td className="py-1.5 text-gray-700">{partida.label}</td>
<td className="py-1.5 text-right text-black font-medium">
{formatEuros(partida.importe)}
</td>
</tr>
))}
</tbody>
</table>
{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>
)}
{/* Subtotal, factor zona, total */}
<div className="flex flex-col gap-1 text-sm border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="text-gray-500">Subtotal</span>
<span className="text-black font-medium">{formatEuros(desglose.subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Factor de zona</span>
<span className="text-black font-medium">×{desglose.factorZona.toFixed(2)}</span>
</div>
<div className="flex justify-between mt-1 pt-2 border-t border-gray-200">
<span className="text-black font-bold">Total estimado</span>
<span className="text-black font-bold text-lg">{formatEuros(desglose.total)}</span>
</div>
</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">
@@ -300,6 +292,18 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
<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>

View File

@@ -0,0 +1,46 @@
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 { 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': `inline; filename="presupuesto-${slug || lead.id}.pdf"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -7,7 +7,8 @@ import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import type { BudgetInputs } from '@/budget/types';
import { applyConceptoEdits } from '@/budget/edit';
import type { BudgetInputs, BudgetResult, PartidaResult } from '@/budget/types';
type Estado = (typeof leads.estado.enumValues)[number];
@@ -61,6 +62,73 @@ export async function marcarGanado(leadId: string, precioFinalEuros: number) {
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

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
<nav className="flex items-center gap-4 text-xs font-medium">
<Link href="/panel" className="text-gray-500 hover:text-black">Leads</Link>
<Link href="/panel/precios" className="text-gray-500 hover:text-black">Precios</Link>
<Link href="/panel/empresa" className="text-gray-500 hover:text-black">Empresa</Link>
<form action="/logout" method="post">
<button type="submit" className="text-gray-500 hover:text-black">Salir</button>
</form>

View File

@@ -3,8 +3,8 @@
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { catalogItems, pricingConfig } from '@/db/schema';
import { getTenantId } from '@/db/pricing-queries';
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.
@@ -76,6 +76,19 @@ export async function actualizarConfig(formData: FormData) {
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> {

View File

@@ -1,9 +1,10 @@
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { getPricingConfig, getCatalog, getEnvioMode } from '@/db/pricing-queries';
import {
crearMaterial,
actualizarPrecio,
borrarMaterial,
actualizarConfig,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
@@ -18,7 +19,11 @@ const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
};
export default async function PreciosPage() {
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
const [config, catalog, envioMode] = await Promise.all([
getPricingConfig(),
getCatalog(),
getEnvioMode(),
]);
return (
<div className="space-y-10">
@@ -30,6 +35,50 @@ export default async function PreciosPage() {
</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>