From bcc882e37d60400b57f5750ea2f68a1843a6ac31 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 3 Jun 2026 19:04:02 +0200 Subject: [PATCH] =?UTF-8?q?Genera=20el=20PDF=20del=20presupuesto=20con=20g?= =?UTF-8?q?aler=C3=ADa=20antes/despu=C3=A9s=20por=20zona?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-presupuesto.ts: construirPresupuestoPdf(leadId) agrupa fotos y notas por zona (fallback al tipoReforma del lead), convierte las imágenes con resolverImagenPdf y arma el PDF. Carga el lead por id sin scoping (uso interno desde el route del panel y desde la finalización pública). - tenant-queries: getTenantPerfilById(tenantId) sin auth; getTenantPerfil lo reutiliza con el tenant de la sesión. - PresupuestoDoc: prop zonas + sección "Imágenes de tu reforma" (antes/después lado a lado + notas por zona). - route del panel: refactor para reutilizar construirPresupuestoPdf (DRY), manteniendo getLead como guardia de auth/404. Co-Authored-By: Claude Opus 4.8 --- .../src/app/panel/[id]/presupuesto/route.ts | 54 ++------- mvp/b2c/src/db/tenant-queries.ts | 52 +++++---- mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx | 57 +++++++++ mvp/b2c/src/lib/pdf/build-presupuesto.ts | 110 ++++++++++++++++++ 4 files changed, 203 insertions(+), 70 deletions(-) create mode 100644 mvp/b2c/src/lib/pdf/build-presupuesto.ts diff --git a/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts b/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts index 3e76f75..9df2495 100644 --- a/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts +++ b/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts @@ -1,12 +1,6 @@ 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 { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info'; -import type { BudgetResult } from '@/budget/types'; -import type { AbstractedPreferences } from '@/lib/voice/preferences'; +import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -16,52 +10,18 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + // getLead aplica el scoping por tenant del panel: sirve de guardia de auth/404. const data = await getLead(id); if (!data) notFound(); + const pdf = await construirPresupuestoPdf(id); + if (!pdf) 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 [logoSrc, imagenSrc] = await Promise.all([ - resolverImagenPdf(empresa.logoUrl, { formato: 'png', maxAncho: 400 }), - resolverImagenPdf(lead.renderUrl, { formato: 'jpeg', maxAncho: 1400 }), - ]); - const prefs = lead.preferencesSnapshot as AbstractedPreferences | null; - const render = imagenSrc - ? { - imagenSrc, - descripcion: construirDescripcionRender({ - calidad: lead.calidadGlobal, - materiales: desglose?.materialesRender ?? [], - estilo: prefs?.estiloRender ?? [], - }), - } - : 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, - logoSrc, - render, - }) - ); - - const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); - return new Response(new Uint8Array(buffer), { + return new Response(new Uint8Array(pdf.buffer), { headers: { 'Content-Type': 'application/pdf', - 'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="presupuesto-${slug || lead.id}.pdf"`, + 'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="${pdf.filename}"`, 'Cache-Control': 'no-store', }, }); diff --git a/mvp/b2c/src/db/tenant-queries.ts b/mvp/b2c/src/db/tenant-queries.ts index f222fd1..0ea7cec 100644 --- a/mvp/b2c/src/db/tenant-queries.ts +++ b/mvp/b2c/src/db/tenant-queries.ts @@ -23,8 +23,29 @@ export type TenantPerfil = { themeColor: string | null; }; -export async function getTenantPerfil(): Promise { - const tenantId = await getTenantId(); +const TENANT_PERFIL_FALLBACK: TenantPerfil = { + nombreEmpresa: 'Reformix', + slug: '', + logoUrl: null, + provincia: null, + cif: null, + direccion: null, + telefono: null, + email: null, + web: null, + seoTitle: null, + seoDescription: null, + aboutEnabled: false, + aboutFotoUrl: null, + aboutTexto: null, + aniosExperiencia: null, + themePreset: 'pizarra', + themeColor: null, +}; + +// Perfil del reformista por id, sin depender del contexto de auth. Lo usa el builder de PDF +// y la finalización, que corren desde el EP público (sin sesión). +export async function getTenantPerfilById(tenantId: string): Promise { const [row] = await db .select({ nombreEmpresa: tenants.nombreEmpresa, @@ -49,27 +70,12 @@ export async function getTenantPerfil(): Promise { .where(eq(tenants.id, tenantId)) .limit(1); - return ( - row ?? { - nombreEmpresa: 'Reformix', - slug: '', - logoUrl: null, - provincia: null, - cif: null, - direccion: null, - telefono: null, - email: null, - web: null, - seoTitle: null, - seoDescription: null, - aboutEnabled: false, - aboutFotoUrl: null, - aboutTexto: null, - aniosExperiencia: null, - themePreset: 'pizarra', - themeColor: null, - } - ); + return row ?? TENANT_PERFIL_FALLBACK; +} + +export async function getTenantPerfil(): Promise { + const tenantId = await getTenantId(); + return getTenantPerfilById(tenantId); } // Galería de trabajos del reformista, para gestionarla desde el panel. diff --git a/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx b/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx index 34ab913..8d209ad 100644 --- a/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx +++ b/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx @@ -137,6 +137,20 @@ const styles = StyleSheet.create({ renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 }, renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 }, renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 }, + zonasSection: { marginTop: 26 }, + zonaBlock: { marginTop: 14 }, + zonaTitle: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black, marginBottom: 6 }, + zonaImagenes: { flexDirection: 'row', gap: 8 }, + zonaCol: { flex: 1 }, + zonaColLabel: { + fontSize: 7, + color: COLOR.gray400, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: 3, + }, + zonaImage: { width: '100%', height: 150, objectFit: 'cover', borderRadius: 6 }, + zonaNota: { marginTop: 4, fontSize: 8, color: COLOR.gray600 }, }); const fmtEuros = (cents: number) => @@ -149,6 +163,13 @@ const fmtEuros = (cents: number) => const fmtFecha = (date: Date) => new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(date); +export type ZonaPdf = { + zonaLabel: string; + antesSrc: string | null; + despuesSrc: string | null; + notas: string[]; +}; + export type PresupuestoDocProps = { empresa: TenantPerfil; cliente: { nombre: string; telefono: string; provincia: string | null }; @@ -156,6 +177,7 @@ export type PresupuestoDocProps = { desglose: BudgetResult | null; logoSrc?: string | null; render?: { imagenSrc: string; descripcion: string } | null; + zonas?: ZonaPdf[]; }; export function PresupuestoDoc({ @@ -165,6 +187,7 @@ export function PresupuestoDoc({ desglose, logoSrc, render, + zonas, }: PresupuestoDocProps) { const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · '); @@ -270,6 +293,40 @@ export function PresupuestoDoc({ ) : null} + {zonas && zonas.length > 0 ? ( + + Imágenes de tu reforma + {zonas.map((z, i) => ( + + {z.zonaLabel} + {z.antesSrc || z.despuesSrc ? ( + + {z.antesSrc ? ( + + Antes + {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */} + + + ) : null} + {z.despuesSrc ? ( + + Después + {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */} + + + ) : null} + + ) : null} + {z.notas.map((n, j) => ( + + • {n} + + ))} + + ))} + + ) : null} + Presupuesto orientativo. El precio final puede variar según la visita técnica. {' · '} diff --git a/mvp/b2c/src/lib/pdf/build-presupuesto.ts b/mvp/b2c/src/lib/pdf/build-presupuesto.ts new file mode 100644 index 0000000..5879f2e --- /dev/null +++ b/mvp/b2c/src/lib/pdf/build-presupuesto.ts @@ -0,0 +1,110 @@ +import { asc, eq } from 'drizzle-orm'; +import { renderToBuffer } from '@react-pdf/renderer'; +import { db } from '@/db'; +import { leads, leadFotos, leadNotas } from '@/db/schema'; +import type { Lead, LeadFoto, LeadNota } from '@/db/schema'; +import { getTenantPerfilById, type TenantPerfil } from '@/db/tenant-queries'; +import { TIPO_LABEL } from '@/lib/funnel'; +import { PresupuestoDoc, type ZonaPdf } from '@/lib/pdf/PresupuestoDoc'; +import { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info'; +import type { BudgetResult } from '@/budget/types'; +import type { AbstractedPreferences } from '@/lib/voice/preferences'; + +export type PresupuestoPdf = { + buffer: Buffer; + filename: string; + lead: Lead; + tenant: TenantPerfil; +}; + +type Tipo = NonNullable; + +// Agrupa fotos y notas por zona (con fallback al tipo de reforma del lead) y devuelve, por zona, +// la primera foto "antes" y la primera "despues" convertidas a data URI que @react-pdf puede +// incrustar, más las notas de texto de esa zona. +async function construirZonas( + fotos: LeadFoto[], + notas: LeadNota[], + tipoLead: Tipo, +): Promise { + const zonas = new Map(); + const slot = (zona: Tipo) => { + let s = zonas.get(zona); + if (!s) { + s = { antes: [], despues: [], notas: [] }; + zonas.set(zona, s); + } + return s; + }; + + for (const f of fotos) { + const zona = (f.zona ?? tipoLead) as Tipo; + slot(zona)[f.momento].push(f); + } + for (const n of notas) { + const texto = n.texto.trim(); + if (texto) slot((n.zona ?? tipoLead) as Tipo).notas.push(texto); + } + + const resultado: ZonaPdf[] = []; + for (const [zona, data] of zonas) { + const [antesSrc, despuesSrc] = await Promise.all([ + resolverImagenPdf(data.antes[0]?.url ?? null, { formato: 'jpeg', maxAncho: 1000 }), + resolverImagenPdf(data.despues[0]?.url ?? null, { formato: 'jpeg', maxAncho: 1000 }), + ]); + if (!antesSrc && !despuesSrc && data.notas.length === 0) continue; + resultado.push({ zonaLabel: TIPO_LABEL[zona], antesSrc, despuesSrc, notas: data.notas }); + } + return resultado; +} + +// Arma el PDF del presupuesto de un lead: desglose real + render + galería antes/después y notas +// por zona. Carga el lead por id sin scoping de tenant (uso interno: lo llaman el route del panel +// y la finalización pública), por eso resuelve el perfil del reformista vía getTenantPerfilById. +export async function construirPresupuestoPdf(leadId: string): Promise { + const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1); + if (!lead) return null; + + const [fotos, notas, tenant] = await Promise.all([ + db.select().from(leadFotos).where(eq(leadFotos.leadId, leadId)).orderBy(asc(leadFotos.orden)), + db.select().from(leadNotas).where(eq(leadNotas.leadId, leadId)).orderBy(asc(leadNotas.createdAt)), + getTenantPerfilById(lead.tenantId), + ]); + + const tipo: Tipo = lead.tipoReforma ?? 'otro'; + const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null; + const desglose = snapshot?.result ?? null; + const prefs = lead.preferencesSnapshot as AbstractedPreferences | null; + + const [logoSrc, renderSrc, zonas] = await Promise.all([ + resolverImagenPdf(tenant.logoUrl, { formato: 'png', maxAncho: 400 }), + resolverImagenPdf(lead.renderUrl, { formato: 'jpeg', maxAncho: 1400 }), + construirZonas(fotos, notas, tipo), + ]); + + const render = renderSrc + ? { + imagenSrc: renderSrc, + descripcion: construirDescripcionRender({ + calidad: lead.calidadGlobal, + materiales: desglose?.materialesRender ?? [], + estilo: prefs?.estiloRender ?? [], + }), + } + : null; + + const buffer = await renderToBuffer( + PresupuestoDoc({ + empresa: tenant, + cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia }, + reforma: { tipoLabel: lead.tipoReforma ? TIPO_LABEL[tipo] : 'Reforma', fecha: lead.createdAt }, + desglose, + logoSrc, + render, + zonas, + }), + ); + + const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); + return { buffer, filename: `presupuesto-${slug || lead.id}.pdf`, lead, tenant }; +}