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

@@ -0,0 +1,248 @@
import { Document, Page, Text, View, Image, StyleSheet } from '@react-pdf/renderer';
import type { BudgetResult } from '@/budget/types';
import type { TenantPerfil } from '@/db/tenant-queries';
const COLOR = {
black: '#0a0a0a',
dark: '#111111',
gray600: '#555555',
gray400: '#888888',
gray200: '#e5e5e5',
gray100: '#f5f5f5',
accent: '#0066ff',
accentLight: '#e8f0fe',
};
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingBottom: 56,
paddingHorizontal: 44,
fontSize: 10,
fontFamily: 'Helvetica',
color: COLOR.dark,
lineHeight: 1.4,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
paddingBottom: 16,
borderBottomWidth: 2,
borderBottomColor: COLOR.black,
},
empresaNombre: { fontSize: 18, fontFamily: 'Helvetica-Bold', color: COLOR.black },
empresaDato: { fontSize: 8, color: COLOR.gray600, marginTop: 1 },
logo: { maxHeight: 48, maxWidth: 140, objectFit: 'contain' },
docTitle: {
marginTop: 18,
fontSize: 13,
fontFamily: 'Helvetica-Bold',
color: COLOR.black,
letterSpacing: 0.5,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 14,
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: COLOR.gray100,
borderRadius: 6,
},
metaLabel: {
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 2,
},
metaValueBold: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black },
metaValue: { fontSize: 9, color: COLOR.gray600 },
tableHead: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: COLOR.gray200,
paddingBottom: 6,
marginTop: 24,
},
thConcepto: {
flex: 1,
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
fontFamily: 'Helvetica-Bold',
},
thImporte: {
width: 90,
textAlign: 'right',
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
fontFamily: 'Helvetica-Bold',
},
row: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: COLOR.gray100,
},
tdConcepto: { flex: 1, fontSize: 10, color: COLOR.dark },
tdImporte: { width: 90, textAlign: 'right', fontSize: 10, fontFamily: 'Helvetica-Bold' },
totalsBox: { marginTop: 16, marginLeft: 'auto', width: 220 },
totalsLine: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 2 },
totalsLabel: { fontSize: 9, color: COLOR.gray600 },
totalsValue: { fontSize: 9, fontFamily: 'Helvetica-Bold', color: COLOR.dark },
totalFinal: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
paddingTop: 10,
paddingHorizontal: 12,
paddingBottom: 10,
backgroundColor: COLOR.accentLight,
borderRadius: 6,
},
totalFinalLabel: { fontSize: 10, fontFamily: 'Helvetica-Bold', color: COLOR.black },
totalFinalValue: { fontSize: 16, fontFamily: 'Helvetica-Bold', color: COLOR.accent },
rango: { marginTop: 8, fontSize: 8, color: COLOR.gray400, textAlign: 'right' },
avisos: { marginTop: 20 },
avisoTitle: {
fontSize: 7,
color: COLOR.gray400,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 4,
},
avisoItem: { fontSize: 8, color: COLOR.gray600, marginBottom: 2 },
footer: {
position: 'absolute',
bottom: 28,
left: 44,
right: 44,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: COLOR.gray200,
fontSize: 7,
color: COLOR.gray400,
textAlign: 'center',
},
empty: { marginTop: 40, fontSize: 11, color: COLOR.gray400, textAlign: 'center' },
});
const fmtEuros = (cents: number) =>
new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
}).format(cents / 100);
const fmtFecha = (date: Date) =>
new Intl.DateTimeFormat('es-ES', { day: '2-digit', month: 'long', year: 'numeric' }).format(date);
export type PresupuestoDocProps = {
empresa: TenantPerfil;
cliente: { nombre: string; telefono: string; provincia: string | null };
reforma: { tipoLabel: string; fecha: Date };
desglose: BudgetResult | null;
};
export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: PresupuestoDocProps) {
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
return (
<Document
title={`Presupuesto ${empresa.nombreEmpresa}${cliente.nombre}`}
author={empresa.nombreEmpresa}
>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.empresaNombre}>{empresa.nombreEmpresa}</Text>
{empresa.cif ? <Text style={styles.empresaDato}>CIF: {empresa.cif}</Text> : null}
{empresa.direccion ? <Text style={styles.empresaDato}>{empresa.direccion}</Text> : null}
{contacto ? <Text style={styles.empresaDato}>{contacto}</Text> : null}
</View>
{empresa.logoUrl ? (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop
<Image src={empresa.logoUrl} style={styles.logo} />
) : null}
</View>
<Text style={styles.docTitle}>PRESUPUESTO ORIENTATIVO DE REFORMA</Text>
<View style={styles.metaRow}>
<View>
<Text style={styles.metaLabel}>Cliente</Text>
<Text style={styles.metaValueBold}>{cliente.nombre}</Text>
<Text style={styles.metaValue}>{cliente.telefono}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={styles.metaLabel}>Reforma</Text>
<Text style={styles.metaValueBold}>{reforma.tipoLabel}</Text>
<Text style={styles.metaValue}>
{(cliente.provincia ?? '—') + ' · ' + fmtFecha(reforma.fecha)}
</Text>
</View>
</View>
{desglose ? (
<>
<View style={styles.tableHead}>
<Text style={styles.thConcepto}>Concepto</Text>
<Text style={styles.thImporte}>Importe</Text>
</View>
{desglose.partidas.map((p, i) => (
<View style={styles.row} key={`${p.key}-${i}`}>
<Text style={styles.tdConcepto}>{p.label}</Text>
<Text style={styles.tdImporte}>{fmtEuros(p.importe)}</Text>
</View>
))}
<View style={styles.totalsBox}>
<View style={styles.totalsLine}>
<Text style={styles.totalsLabel}>Subtotal</Text>
<Text style={styles.totalsValue}>{fmtEuros(desglose.subtotal)}</Text>
</View>
<View style={styles.totalsLine}>
<Text style={styles.totalsLabel}>Factor de zona</Text>
<Text style={styles.totalsValue}>×{desglose.factorZona.toFixed(2)}</Text>
</View>
<View style={styles.totalFinal}>
<Text style={styles.totalFinalLabel}>Total estimado</Text>
<Text style={styles.totalFinalValue}>{fmtEuros(desglose.total)}</Text>
</View>
{desglose.rango.min !== desglose.rango.max ? (
<Text style={styles.rango}>
Rango: {fmtEuros(desglose.rango.min)} {fmtEuros(desglose.rango.max)}
</Text>
) : null}
</View>
{desglose.avisos.length > 0 ? (
<View style={styles.avisos}>
<Text style={styles.avisoTitle}>Notas</Text>
{desglose.avisos.map((a, i) => (
<Text style={styles.avisoItem} key={i}>
{a}
</Text>
))}
</View>
) : null}
</>
) : (
<Text style={styles.empty}>Este lead aún no tiene presupuesto calculado.</Text>
)}
<Text style={styles.footer} fixed>
Presupuesto orientativo. El precio final puede variar según la visita técnica.
{' · '}
{empresa.nombreEmpresa}
</Text>
</Page>
</Document>
);
}