Genera el PDF del presupuesto con galería antes/después por zona
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,6 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { renderToBuffer } from '@react-pdf/renderer';
|
|
||||||
import { getLead } from '@/db/queries';
|
import { getLead } from '@/db/queries';
|
||||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
import { construirPresupuestoPdf } from '@/lib/pdf/build-presupuesto';
|
||||||
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';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -16,52 +10,18 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
// getLead aplica el scoping por tenant del panel: sirve de guardia de auth/404.
|
||||||
const data = await getLead(id);
|
const data = await getLead(id);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const pdf = await construirPresupuestoPdf(id);
|
||||||
|
if (!pdf) notFound();
|
||||||
|
|
||||||
const descargar = new URL(req.url).searchParams.get('download') === '1';
|
const descargar = new URL(req.url).searchParams.get('download') === '1';
|
||||||
|
return new Response(new Uint8Array(pdf.buffer), {
|
||||||
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), {
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/pdf',
|
'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',
|
'Cache-Control': 'no-store',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,8 +23,29 @@ export type TenantPerfil = {
|
|||||||
themeColor: string | null;
|
themeColor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
const TENANT_PERFIL_FALLBACK: TenantPerfil = {
|
||||||
const tenantId = await getTenantId();
|
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<TenantPerfil> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
nombreEmpresa: tenants.nombreEmpresa,
|
nombreEmpresa: tenants.nombreEmpresa,
|
||||||
@@ -49,27 +70,12 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
|||||||
.where(eq(tenants.id, tenantId))
|
.where(eq(tenants.id, tenantId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return (
|
return row ?? TENANT_PERFIL_FALLBACK;
|
||||||
row ?? {
|
}
|
||||||
nombreEmpresa: 'Reformix',
|
|
||||||
slug: '',
|
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||||
logoUrl: null,
|
const tenantId = await getTenantId();
|
||||||
provincia: null,
|
return getTenantPerfilById(tenantId);
|
||||||
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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Galería de trabajos del reformista, para gestionarla desde el panel.
|
// Galería de trabajos del reformista, para gestionarla desde el panel.
|
||||||
|
|||||||
@@ -137,6 +137,20 @@ const styles = StyleSheet.create({
|
|||||||
renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 },
|
renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 },
|
||||||
renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
|
renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
|
||||||
renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 },
|
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) =>
|
const fmtEuros = (cents: number) =>
|
||||||
@@ -149,6 +163,13 @@ const fmtEuros = (cents: number) =>
|
|||||||
const fmtFecha = (date: Date) =>
|
const fmtFecha = (date: Date) =>
|
||||||
new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(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 = {
|
export type PresupuestoDocProps = {
|
||||||
empresa: TenantPerfil;
|
empresa: TenantPerfil;
|
||||||
cliente: { nombre: string; telefono: string; provincia: string | null };
|
cliente: { nombre: string; telefono: string; provincia: string | null };
|
||||||
@@ -156,6 +177,7 @@ export type PresupuestoDocProps = {
|
|||||||
desglose: BudgetResult | null;
|
desglose: BudgetResult | null;
|
||||||
logoSrc?: string | null;
|
logoSrc?: string | null;
|
||||||
render?: { imagenSrc: string; descripcion: string } | null;
|
render?: { imagenSrc: string; descripcion: string } | null;
|
||||||
|
zonas?: ZonaPdf[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PresupuestoDoc({
|
export function PresupuestoDoc({
|
||||||
@@ -165,6 +187,7 @@ export function PresupuestoDoc({
|
|||||||
desglose,
|
desglose,
|
||||||
logoSrc,
|
logoSrc,
|
||||||
render,
|
render,
|
||||||
|
zonas,
|
||||||
}: PresupuestoDocProps) {
|
}: PresupuestoDocProps) {
|
||||||
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
|
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
@@ -270,6 +293,40 @@ export function PresupuestoDoc({
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{zonas && zonas.length > 0 ? (
|
||||||
|
<View style={styles.zonasSection}>
|
||||||
|
<Text style={styles.renderTitle}>Imágenes de tu reforma</Text>
|
||||||
|
{zonas.map((z, i) => (
|
||||||
|
<View style={styles.zonaBlock} key={`${z.zonaLabel}-${i}`} wrap={false}>
|
||||||
|
<Text style={styles.zonaTitle}>{z.zonaLabel}</Text>
|
||||||
|
{z.antesSrc || z.despuesSrc ? (
|
||||||
|
<View style={styles.zonaImagenes}>
|
||||||
|
{z.antesSrc ? (
|
||||||
|
<View style={styles.zonaCol}>
|
||||||
|
<Text style={styles.zonaColLabel}>Antes</Text>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
|
||||||
|
<Image src={z.antesSrc} style={styles.zonaImage} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{z.despuesSrc ? (
|
||||||
|
<View style={styles.zonaCol}>
|
||||||
|
<Text style={styles.zonaColLabel}>Después</Text>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
|
||||||
|
<Image src={z.despuesSrc} style={styles.zonaImage} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{z.notas.map((n, j) => (
|
||||||
|
<Text style={styles.zonaNota} key={j}>
|
||||||
|
• {n}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Text style={styles.footer} fixed>
|
<Text style={styles.footer} fixed>
|
||||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||||
{' · '}
|
{' · '}
|
||||||
|
|||||||
110
mvp/b2c/src/lib/pdf/build-presupuesto.ts
Normal file
110
mvp/b2c/src/lib/pdf/build-presupuesto.ts
Normal file
@@ -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<Lead['tipoReforma']>;
|
||||||
|
|
||||||
|
// 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<ZonaPdf[]> {
|
||||||
|
const zonas = new Map<Tipo, { antes: LeadFoto[]; despues: LeadFoto[]; notas: string[] }>();
|
||||||
|
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<PresupuestoPdf | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user