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:
248
mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
Normal file
248
mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user